|
| 1 | +package t::lib::PPI::Test::Cmp; |
| 2 | + |
| 3 | +use warnings; |
| 4 | +use strict; |
| 5 | + |
| 6 | +use Exporter (); |
| 7 | +use List::Util (); |
| 8 | +use PPI; |
| 9 | +use Scalar::Util qw( blessed ); |
| 10 | +use Test::More; |
| 11 | + |
| 12 | +use vars qw{$VERSION @ISA @EXPORT @EXPORT_OK}; |
| 13 | +BEGIN { |
| 14 | + $VERSION = '1.220'; |
| 15 | + @ISA = 'Exporter'; |
| 16 | + @EXPORT = qw( |
| 17 | + cmp_document cmp_sdocument |
| 18 | + cmp_statement cmp_sstatement |
| 19 | + cmp_element cmp_selement |
| 20 | + ); |
| 21 | + @EXPORT_OK = @EXPORT; |
| 22 | +} |
| 23 | + |
| 24 | +use constant CMP_CONTEXT_BEFORE => 4; |
| 25 | +use constant CMP_CONTEXT_AFTER => 2; |
| 26 | + |
| 27 | + |
| 28 | +=pod |
| 29 | +
|
| 30 | +=head1 NAME |
| 31 | +
|
| 32 | +t::lib::PPI::Test::Cmp - check the results of parsing code snippets |
| 33 | +
|
| 34 | +=head1 TEST FUNCTIONS |
| 35 | +
|
| 36 | +=head2 cmp_document( $code, \@expected [, $msg ] ) |
| 37 | +
|
| 38 | +Parses C<code> into a new PPI::Document and checks the resulting |
| 39 | +elements one by one against C<expected>, failing the test if the |
| 40 | +two do not compare correctly. |
| 41 | +
|
| 42 | +Each element of C<expected> is a hashref whose keys describe how to |
| 43 | +compare it to the corresponding element from the parse. |
| 44 | +Keys supported: |
| 45 | +
|
| 46 | +=over 4 |
| 47 | +
|
| 48 | +=item class |
| 49 | +
|
| 50 | +The value of C<class> is compared to the parsed element's class. |
| 51 | +
|
| 52 | +=item isa |
| 53 | +
|
| 54 | +The value of C<isa> is passed to an isa call on parsed element. |
| 55 | +
|
| 56 | +=item name of any method on the parsed PPI element: |
| 57 | +
|
| 58 | +Any key not otherwise documented is used as a method name on the |
| 59 | +parsed element. The results of the method call must match the key's |
| 60 | +value. If the element being compared does not have that method, the test |
| 61 | +will fail. |
| 62 | +
|
| 63 | +=item string containing '::' plus a scalar |
| 64 | +
|
| 65 | +Because it can be tedious to check a parsed element for just class and |
| 66 | +content, instead of: |
| 67 | +
|
| 68 | + { class => 'PPI::Foo', content => 'bar' } |
| 69 | +
|
| 70 | +keys that look like class names are special-cased so you can write: |
| 71 | +
|
| 72 | + { PPI::Foo => 'bar' } |
| 73 | +
|
| 74 | +=item FUNC |
| 75 | +
|
| 76 | +The value for this key is a sub that accepts the parsed element |
| 77 | +as its argument, along with a test description. Execute as many tests |
| 78 | +on anything you like in the sub. E.g.: |
| 79 | +
|
| 80 | + FUNC => sub { |
| 81 | + my ( $elem, $msg ) = @_; |
| 82 | + is_deeply( [$elem->foo()], [1, 2, 3], "$msg: testing foo" ); |
| 83 | + } |
| 84 | +
|
| 85 | +The return value of the sub is ignored. |
| 86 | +
|
| 87 | +=item STOP |
| 88 | +
|
| 89 | +When the key STOP has a true value, the test stops after all the other |
| 90 | +keys in that hash has been processed. |
| 91 | +
|
| 92 | +=item NODESCEND |
| 93 | +
|
| 94 | +When the key NODESCEND has a true value, |
| 95 | +no children of the parsed element will be visited. |
| 96 | +The children must therefore not appear in C<expected>. |
| 97 | +
|
| 98 | +=back |
| 99 | +
|
| 100 | +The return is true for a successful test, false otherwise. |
| 101 | +
|
| 102 | +=head2 cmp_sdocument( $code, \@expected [, $msg ] ) |
| 103 | +
|
| 104 | +The variant C<cmp_sdocument> ignores insignificant elements in C<expected>. |
| 105 | +
|
| 106 | +=cut |
| 107 | + |
| 108 | +sub cmp_document { |
| 109 | + my ( $code, $expected, $msg ) = @_; |
| 110 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 111 | + return _cmp_document( $code, $expected, $msg, 0 ); |
| 112 | +} |
| 113 | + |
| 114 | +sub cmp_sdocument { |
| 115 | + my ( $code, $expected, $msg ) = @_; |
| 116 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 117 | + return _cmp_document( $code, $expected, $msg, 1 ); |
| 118 | +} |
| 119 | + |
| 120 | +sub _cmp_document { |
| 121 | + my ( $code, $expected, $msg, $significant_only ) = @_; |
| 122 | + |
| 123 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 124 | + |
| 125 | + $msg = 'cmp_document: ' . (defined $msg ? $msg : $code); |
| 126 | + |
| 127 | + return subtest $msg => sub { |
| 128 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 129 | + |
| 130 | + my $doc = PPI::Document->new( \$code ); |
| 131 | + |
| 132 | + my $index = -1; |
| 133 | + my $failed_at = -1; |
| 134 | + my $stopped_at = -1; |
| 135 | + my $state = { |
| 136 | + significant_only => $significant_only, |
| 137 | + extracted => [], # list of elements extracted from $doc so far |
| 138 | + expected => $expected, # complete list of expected results |
| 139 | + indexref => \$index, # reference to current index in 'expected'/'extracted' |
| 140 | + failed_at => \$failed_at, # reference to first failure point in 'extracted'/'extracted' |
| 141 | + stopped_at => \$stopped_at, # whether extraction should stop |
| 142 | + }; |
| 143 | + __cmp( $doc, $state ); |
| 144 | + my $num_extracted = scalar( @{ $state->{extracted} } ); |
| 145 | + if ( $stopped_at < 0 && $failed_at < 0 && $num_extracted < scalar(@$expected) ) { |
| 146 | + fail( "[$num_extracted]: ran out of parsed elements" ); |
| 147 | + $failed_at = $num_extracted; |
| 148 | + } |
| 149 | + if ( $failed_at >= 0 ) { |
| 150 | + _report_side_by_side( $state->{extracted}, $expected, ${ $state->{failed_at} } ); |
| 151 | + } |
| 152 | + }; |
| 153 | +} |
| 154 | + |
| 155 | +# "Extract" more elements from the document until the 'stopped_at' flag is set. |
| 156 | +sub __cmp { |
| 157 | + my ( $elem, $state ) = @_; |
| 158 | + |
| 159 | + return if $state->{significant_only} && !$elem->significant; |
| 160 | + |
| 161 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 162 | + |
| 163 | + # Consider $elem to have been extracted. |
| 164 | + my $index = ++${ $state->{indexref} }; |
| 165 | + my $indexmsg = "[$index]:"; |
| 166 | + push @{ $state->{extracted} }, $elem; |
| 167 | + |
| 168 | + my $nodescend; |
| 169 | + |
| 170 | + if ( $index < scalar(@{$state->{expected}}) ) { |
| 171 | + my $want = $state->{expected}->[ $index ]; |
| 172 | + $nodescend = 1 if $want->{NODESCEND}; |
| 173 | + |
| 174 | + my $failed; |
| 175 | + |
| 176 | + if ( exists $want->{class} ) { |
| 177 | + $failed ||= !is( ref($elem), $want->{class}, "$indexmsg class matches" ); |
| 178 | + } |
| 179 | + if ( exists $want->{isa} ) { |
| 180 | + $failed ||= !isa_ok( $elem, $want->{isa}, "$indexmsg class " . ref($elem) ); |
| 181 | + } |
| 182 | + foreach my $key ( keys %$want ) { |
| 183 | + next if $key eq 'class' || $key eq 'isa' || $key eq 'STOP' || $key eq 'NODESCEND'; |
| 184 | + |
| 185 | + if ( $elem->can($key) ) { |
| 186 | + # Test results of method named $key. |
| 187 | + my $val = $elem->$key; |
| 188 | + $failed ||= !is( $val, $want->{$key}, "$indexmsg $key matches" ); |
| 189 | + } |
| 190 | + elsif ( $key eq 'FUNC' ) { |
| 191 | + # Execute the caller's function, ignoring the return. |
| 192 | + $want->{$key}->( $elem, "$indexmsg arbitrary tests" ); |
| 193 | + } |
| 194 | + elsif ( $key =~ /::/ && !ref $want->{$key} ) { |
| 195 | + # Test key as 'class' and the value as 'content'. |
| 196 | + $failed ||= !isa_ok( $elem, $key, "$indexmsg class " . ref($elem) . " isa $key" ); |
| 197 | + $failed ||= !is( $elem->content, $want->{$key}, "$indexmsg content matches" ); |
| 198 | + } |
| 199 | + else { |
| 200 | + $failed ||= !fail( "$indexmsg no method $key on object of type " . ref($elem) ); |
| 201 | + } |
| 202 | + } |
| 203 | + ${ $state->{failed_at} } = $index if $failed && ${ $state->{failed_at} } < 0; |
| 204 | + ${ $state->{stopped_at} } = $index if $want->{STOP}; # last thing from $want to check |
| 205 | + } |
| 206 | + elsif ( $index == scalar(@{$state->{expected}}) ) { |
| 207 | + # We just ran out of results, so fail here. |
| 208 | + fail( "$indexmsg ran out of expected results " . ref($elem) ); |
| 209 | + ${ $state->{failed_at} } = $index if ${ $state->{failed_at} } < 0; |
| 210 | + } |
| 211 | + |
| 212 | + # Extract and/or compare more elements if we need to. |
| 213 | + # Keep extracting after failures, since we need to display |
| 214 | + # elements after the failed one. |
| 215 | + if ( ${ $state->{stopped_at} } < 0 && !$nodescend ) { |
| 216 | + my $max_extract = |
| 217 | + ${ $state->{failed_at} } >= 0 |
| 218 | + ? ${ $state->{failed_at} } + CMP_CONTEXT_AFTER |
| 219 | + : scalar(@{ $state->{expected} }) + CMP_CONTEXT_AFTER |
| 220 | + ; |
| 221 | + if ( $index < $max_extract ) { |
| 222 | + foreach my $child ( $elem->isa('PPI::Structure') ? $elem->elements : $elem->isa('PPI::Node') ? $elem->children : () ) { |
| 223 | + __cmp( $child, $state ); |
| 224 | + last if ${ $state->{stopped_at} } >= 0; |
| 225 | + } |
| 226 | + } |
| 227 | + else { |
| 228 | + ${ $state->{stopped_at} } = $index; |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + return; |
| 233 | +} |
| 234 | + |
| 235 | + |
| 236 | +sub _report_side_by_side { |
| 237 | + my $parsed = shift; |
| 238 | + my $expected = shift; |
| 239 | + my $offending_index = shift; |
| 240 | + |
| 241 | + my $both_maxidx = List::Util::max( scalar(@$parsed)-1, scalar(@$expected)-1 ); |
| 242 | + my $first_index = List::Util::max( $offending_index - CMP_CONTEXT_BEFORE, 0 ); |
| 243 | + my $last_index = List::Util::min( $offending_index + CMP_CONTEXT_AFTER, $both_maxidx ); |
| 244 | + |
| 245 | + my @parsed_descriptions = map { defined $parsed->[$_] ? ref $parsed->[$_] : '' } ( $first_index .. $last_index ); |
| 246 | + my @expected_descriptions = map { defined $expected->[$_] ? _hash_to_str($expected->[$_]) : '' } ( $first_index .. $last_index ); |
| 247 | + |
| 248 | + my $parsed_max_len = List::Util::max map { length($_) } @parsed_descriptions; |
| 249 | + my $expected_max_len = List::Util::max map { length($_) } @expected_descriptions; |
| 250 | + my $last_index_len = length( $last_index ); |
| 251 | + my @output = sprintf( '%s %*s %-*s %-*s', ' ', $last_index_len+2, '', $parsed_max_len, 'parsed', $expected_max_len, 'expected' ); |
| 252 | + for my $i ( $first_index .. $last_index ) { |
| 253 | + push @output, |
| 254 | + sprintf( |
| 255 | + '%s [%*d] %-*s %-*s %s', |
| 256 | + ($i == $offending_index ? '>>>' : ' '), |
| 257 | + $last_index_len, $i, |
| 258 | + $parsed_max_len, $parsed_descriptions[$i - $first_index], |
| 259 | + $expected_max_len, $expected_descriptions[$i - $first_index], |
| 260 | + ($i == $offending_index ? '<<<' : ' '), |
| 261 | + ); |
| 262 | + } |
| 263 | + diag join( "\n", '', @output ); |
| 264 | + |
| 265 | + return; |
| 266 | +} |
| 267 | + |
| 268 | + |
| 269 | +=pod |
| 270 | +
|
| 271 | +=head2 cmp_statement( $code, \@expected [, $msg ] ) |
| 272 | +
|
| 273 | +=head2 cmp_statement( $code, \%expected [, $msg ] ) |
| 274 | +
|
| 275 | +=head2 cmp_sstatement( $code, \@expected [, $msg ] ) |
| 276 | +
|
| 277 | +=head2 cmp_sstatement( $code, \%expected [, $msg ] ) |
| 278 | +
|
| 279 | +A convenience function that behaves like C<cmp_document>, except that |
| 280 | +you omit the C<PPI::Document> element at the beginning of C<expected>. |
| 281 | +
|
| 282 | +The variant C<cmp_sstatement> ignores insignificant elements in the |
| 283 | +document so that you can omit them from C<expected>. |
| 284 | +
|
| 285 | +C<expected> can be passed as a hashref if you have only one element to |
| 286 | +compare. |
| 287 | +
|
| 288 | +The return is true for a successful test, false otherwise. |
| 289 | +
|
| 290 | +=cut |
| 291 | + |
| 292 | +sub cmp_statement { |
| 293 | + my ( $code, $expected, $msg ) = @_; |
| 294 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 295 | + return _cmp_statement( $code, $expected, $msg, 0 ); |
| 296 | +} |
| 297 | + |
| 298 | +sub cmp_sstatement { |
| 299 | + my ( $code, $expected, $msg ) = @_; |
| 300 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 301 | + return _cmp_statement( $code, $expected, $msg, 1 ); |
| 302 | +} |
| 303 | + |
| 304 | + |
| 305 | +sub _cmp_statement { |
| 306 | + my ( $code, $expected, $msg, $significant_only ) = @_; |
| 307 | + |
| 308 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 309 | + |
| 310 | + $expected = [ $expected ] if ref( $expected ) ne 'ARRAY'; |
| 311 | + $expected = [ { class => 'PPI::Document' }, @$expected ]; |
| 312 | + |
| 313 | + return _cmp_document( $code, $expected, $msg, $significant_only ); |
| 314 | +} |
| 315 | + |
| 316 | + |
| 317 | +=pod |
| 318 | +
|
| 319 | +=head2 cmp_element( $code, \%expected [, $msg ] ) |
| 320 | +
|
| 321 | +=head2 cmp_element( $code, \@expected [, $msg ] ) |
| 322 | +
|
| 323 | +=head2 cmp_selement( $code, \%expected [, $msg ] ) |
| 324 | +
|
| 325 | +=head2 cmp_selement( $code, \@expected [, $msg ] ) |
| 326 | +
|
| 327 | +A convenience function that behaves like C<cmp_document>, except that |
| 328 | +C<expected> is a single hashref. The parsed document's initial |
| 329 | +C<PPI::Document> and C<PPI::Statement> are ignored, and comparison |
| 330 | +begins with the element following the statement. |
| 331 | +
|
| 332 | +You can also pass a listref of hashes for C<expected>, in which case |
| 333 | +all elements in C<expected> must match. |
| 334 | +
|
| 335 | +The variant C<cmp_selement> ignores insignificant elements in the |
| 336 | +document so that you can omit them from C<expected>. |
| 337 | +
|
| 338 | +The return is true for a successful test, false otherwise. |
| 339 | +
|
| 340 | +=cut |
| 341 | + |
| 342 | +sub cmp_element { |
| 343 | + my ( $code, $expected, $msg ) = @_; |
| 344 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 345 | + return _cmp_element( $code, $expected, $msg, 0 ); |
| 346 | +} |
| 347 | + |
| 348 | +sub cmp_selement { |
| 349 | + my ( $code, $expected, $msg ) = @_; |
| 350 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 351 | + return _cmp_element( $code, $expected, $msg, 1 ); |
| 352 | +} |
| 353 | + |
| 354 | +sub _cmp_element { |
| 355 | + my ( $code, $expected, $msg, $significant_only ) = @_; |
| 356 | + |
| 357 | + local $Test::Builder::Level = $Test::Builder::Level + 1; |
| 358 | + |
| 359 | + $expected = [ $expected ] if ref( $expected ) ne 'ARRAY'; |
| 360 | + $expected = [ { class => 'PPI::Document' }, { isa => 'PPI::Statement' }, @$expected ]; |
| 361 | + |
| 362 | + return _cmp_document( $code, $expected, $msg, $significant_only ); |
| 363 | +} |
| 364 | + |
| 365 | + |
| 366 | +sub _hash_to_str { |
| 367 | + my $hash = shift; |
| 368 | + my $str = '{ ' . join(', ', map { "$_ => " . (defined $hash->{$_} ? $hash->{$_} : 'undef') } keys %$hash) . ' }'; |
| 369 | + return $str; |
| 370 | +} |
| 371 | + |
| 372 | + |
| 373 | +1; |
0 commit comments