diff --git a/t/lib/PPI/Test.pm b/t/lib/PPI/Test.pm index d60efbbf..728fb8bd 100644 --- a/t/lib/PPI/Test.pm +++ b/t/lib/PPI/Test.pm @@ -5,9 +5,335 @@ use strict; use File::Spec::Functions (); +our %EXPORT_TAGS = ( + 'cmp' => [ qw( + cmp_document cmp_sdocument + cmp_statement cmp_sstatement + cmp_element cmp_selement + ) ] +); our @ISA = 'Exporter'; -our @EXPORT_OK = qw( find_files quotable pause ); -our %EXPORT_TAGS; +our @EXPORT_OK = ( qw( pause find_files quotable ), map { @{ $EXPORT_TAGS{$_} } } keys %EXPORT_TAGS ); + +use Exporter (); +use List::MoreUtils (); +use List::Util (); +use Scalar::Util qw( blessed ); +use Test::More; + +=pod + +=head1 NAME + +PPI::Test - stuff to help with testing PPI + +=head1 TEST FUNCTIONS + +=head2 cmp_document( $code, \@expected [, $msg ] ) + +=head2 cmp_sdocument( $code, \@expected [, $msg ] ) + +Parses C into a new PPI::Document and checks the resulting +elements one by one against C, failing the test if the +two do not compare correctly. + +The variant C ignores insignificant elements in the +document so that you can omit them from C. + +Each element of C is a hashref whose keys describe how to +compare it to the corresponding element from the parse. +Hash keys supported: + +=over 4 + +=item class + +The value of C is compared to the parsed element's class. + +=item isa + +The value of C is passed to an isa call on parsed element. + +=item name of any method on the parsed PPI element: + +Any hash key not otherwise documented is used as a method name on the +parsed element; the results of the method call must match the hash key's +value. If the element being compared does not have that method, the test +will fail. + +=item FUNC + +The value for this attribute is a sub that accepts the parsed element +as its argument, along with a test description. Execute as many tests +on anything you like in the sub. E.g.: + + FUNC => sub { + my ( $elem, $msg ) = @_; + is_deeply( [$elem->foo()], [1, 2, 3], "$msg: testing foo" ); + } + +The return value of the sub is ignored. + +=item STOP + +When the key STOP appears with a true value in C, +comparison stops after that hash has been compared. + +=back + +The return is true for a successful test, false otherwise. + +=cut + +sub cmp_document { + my $code = shift; + my $expected = shift; + my $msg = shift; + local $Test::Builder::Level = $Test::Builder::Level + 1; + return _cmp_document( $code, $expected, $msg, 0 ); +} + +sub cmp_sdocument { + my $code = shift; + my $expected = shift; + my $msg = shift; + local $Test::Builder::Level = $Test::Builder::Level + 1; + return _cmp_document( $code, $expected, $msg, 1 ); +} + +sub _cmp_document { + my $code = shift; + my $expected = shift; + my $msg = shift; + my $significant_only = shift; + + local $Test::Builder::Level = $Test::Builder::Level + 1; + + $msg = 'cmp_document: ' . (defined $msg ? $msg : $code); + + return subtest $msg => sub { + my $doc = PPI::Document->new( \$code ); + + my $parsed = _as_array( $doc, { significant_only => $significant_only } ); + + my $iterator = List::MoreUtils::each_arrayref( $parsed, $expected ); + my $index = 0; + my $dump = 0; + while ( my ($elem, $want) = $iterator->() ) { + my $indexmsg = "[$index]:"; + if ( !defined $want ) { + $dump = !fail( "$indexmsg ran out of expected results for parsed element " . ref($elem) ) || $dump; + last; + } + if ( !defined $elem ) { + $dump = !fail( "$indexmsg ran out of parsed elements for expected result " . _hash_to_str($want) ) || $dump; + last; + } + $dump = !ok( blessed $elem, "$indexmsg parsed element is an object" ) || $dump; + + if ( exists $want->{class} ) { + $dump = !is( ref($elem), $want->{class}, "$indexmsg class matches" ) || $dump; + } + if ( exists $want->{isa} ) { + $dump = !isa_ok( $elem, $want->{isa}, "$indexmsg class " . ref($elem) . " isa $want->{isa}" ) || $dump; + } + foreach my $key ( keys %$want ) { + next if $key eq 'class' || $key eq 'isa' || $key eq 'STOP'; + if ( $elem->can($key) ) { + my $val = $elem->$key; + $dump = !is( $val, $want->{$key}, "$indexmsg $key matches" ) || $dump; + } + elsif ( $key eq 'FUNC' ) { + # Execute the caller's function, ignoring the return. + $want->{$key}->( $elem, "$indexmsg arbitrary tests" ); + } + else { + $dump = !fail( "$indexmsg no method $key on object of type " . ref($elem) ) || $dump; + } + } + + last if $dump; + last if $want->{STOP}; + + ++$index; + } + + if ( $dump ) { + _report_side_by_side( $parsed, $expected, $index ); + } + }; +} + + +sub _report_side_by_side { + my $parsed = shift; + my $expected = shift; + my $offending_index = shift; + + my $both_maxidx = List::Util::max( scalar(@$parsed)-1, scalar(@$expected)-1 ); + my $first_index = List::Util::max( $offending_index-4, 0 ); + my $last_index = List::Util::min( $offending_index+1, $both_maxidx ); + + my @parsed_descriptions = map { defined $parsed->[$_] ? ref $parsed->[$_] : '' } ( $first_index .. $last_index ); + my @expected_descriptions = map { defined $expected->[$_] ? _hash_to_str($expected->[$_]) : '' } ( $first_index .. $last_index ); + + my $parsed_max_len = List::Util::max map { length($_) } @parsed_descriptions; + my $expected_max_len = List::Util::max map { length($_) } @expected_descriptions; + my $last_index_len = length( $last_index ); + my @output; + for my $i ( $first_index .. $last_index ) { + push @output, + sprintf( + '%s [%*d] %-*s %-*s %s', + ($i == $offending_index ? '>>>' : ' '), + $last_index_len, $i, + $parsed_max_len, $parsed_descriptions[$i - $first_index], + $expected_max_len, $expected_descriptions[$i - $first_index], + ($i == $offending_index ? '<<<' : ' '), + ); + } + diag join( "\n", '', @output ); + + return; +} + + +=pod + +=head2 cmp_statement( $code, \@expected [, $msg ] ) + +=head2 cmp_statement( $code, \%expected [, $msg ] ) + +=head2 cmp_sstatement( $code, \@expected [, $msg ] ) + +=head2 cmp_sstatement( $code, \%expected [, $msg ] ) + +A convenience function that behaves like C, except that +you don't have to have a C element at the beginning of +C. + +The variant C ignores insignificant elements in the +document so that you can omit them from C. + +C can be passed as a hashref if you have only one element to +compare. + +The return is true for a successful test, false otherwise. + +=cut + +sub cmp_statement { + my $code = shift; + my $expected = shift; + my $msg = shift; + local $Test::Builder::Level = $Test::Builder::Level + 1; + return _cmp_statement( $code, $expected, $msg, 0 ); +} + +sub cmp_sstatement { + my $code = shift; + my $expected = shift; + my $msg = shift; + local $Test::Builder::Level = $Test::Builder::Level + 1; + return _cmp_statement( $code, $expected, $msg, 1 ); +} + + +sub _cmp_statement { + my $code = shift; + my $expected = shift; + my $msg = shift; + my $significant_only = shift; + + local $Test::Builder::Level = $Test::Builder::Level + 1; + + $expected = [ $expected ] if ref( $expected ) ne 'ARRAY'; + $expected = [ { class => 'PPI::Document' }, @$expected ]; + + return _cmp_document( $code, $expected, $significant_only ); +} + + +=pod + +=head2 cmp_element( $code, \%expected [, $msg ] ) + +=head2 cmp_element( $code, \@expected [, $msg ] ) + +=head2 cmp_selement( $code, \%expected [, $msg ] ) + +=head2 cmp_selement( $code, \@expected [, $msg ] ) + +A convenience function that behaves like C, except that +C is a single hashref. The parsed document's initial +C and C are ignored, and comparison +begins with the element following the statement. + +You can also pass a listref of hashes for C, in which case +all elements in C must match. + +The variant C ignores insignificant elements in the +document so that you can omit them from C. + +The return is true for a successful test, false otherwise. + +=cut + +sub cmp_element { + my $code = shift; + my $expected = shift; + my $msg = shift; + local $Test::Builder::Level = $Test::Builder::Level + 1; + return _cmp_element( $code, $expected, $msg, 0 ); +} + +sub cmp_selement { + my $code = shift; + my $expected = shift; + my $msg = shift; + local $Test::Builder::Level = $Test::Builder::Level + 1; + return _cmp_element( $code, $expected, $msg, 1 ); +} + +sub _cmp_element { + my $code = shift; + my $expected = shift; + my $msg = shift; + my $significant_only = shift; + + local $Test::Builder::Level = $Test::Builder::Level + 1; + + $expected = [ $expected ] if ref( $expected ) ne 'ARRAY'; + $expected = [ { class => 'PPI::Document' }, { isa => 'PPI::Statement' }, @$expected ]; + + return _cmp_document( $code, $expected, $msg, $significant_only ); +} + + +sub _as_array { + my $elem = shift; + my $opts = shift; + my $output = shift || []; + + if ( !$opts->{significant_only} || $elem->significant ) { + push @$output, $elem; + } + + # Recurse into our children + foreach my $child ( @{$elem->{children}} ) { + _as_array( $child, $opts, $output ); + } + + return $output; +} + + +sub _hash_to_str { + my $hash = shift; + my $str = '{ ' . join(', ', map { "$_ => $hash->{$_}" } keys %$hash) . ' }'; + return $str; +} + # Find file names in named t/data dirs sub find_files { diff --git a/t/lib/PPI/Test/Object.pm b/t/lib/PPI/Test/Object.pm index 1b0cbb4e..2e0afb8a 100644 --- a/t/lib/PPI/Test/Object.pm +++ b/t/lib/PPI/Test/Object.pm @@ -4,180 +4,141 @@ use warnings; use strict; use List::Util 1.33 'any'; -use Params::Util qw{_INSTANCE}; -use PPI::Dumper; +use Params::Util qw{_STRING _INSTANCE}; +use File::Spec::Functions ':ALL'; use Test::More; use Test::Object 0.07; +sub r { + my ( $class, $tests, $code ) = @_; + Test::Object->register( class => $class, tests => $tests, code => $code ); +} + ##################################################################### # PPI::Document Testing -Test::Object->register( - class => 'PPI::Document', - tests => 1, - code => \&document_ok, +r( + 'PPI::Document', 1, sub { + my $doc = shift; + + # A document should have zero or more children that are either + # a statement or a non-significant child. + my @children = $doc->children; + my $good = grep { + _INSTANCE($_, 'PPI::Statement') + or ( + _INSTANCE($_, 'PPI::Token') and ! $_->significant + ) + } @children; + + is( $good, scalar(@children), + 'Document contains only statements and non-significant tokens' ); + + 1; + } ); -sub document_ok { - my $doc = shift; - - # A document should have zero or more children that are either - # a statement or a non-significant child. - my @children = $doc->children; - my $good = grep { - _INSTANCE($_, 'PPI::Statement') - or ( - _INSTANCE($_, 'PPI::Token') and ! $_->significant - ) - } @children; - - is( $good, scalar(@children), - 'Document contains only statements and non-significant tokens' ); - - 1; -} - - - - - ##################################################################### # Are there an unknowns -Test::Object->register( - class => 'PPI::Document', - tests => 3, - code => \&unknown_objects, +r( + 'PPI::Document', 3, sub { + my $doc = shift; + + is( + $doc->find_any('Token::Unknown'), + '', + "Contains no PPI::Token::Unknown elements", + ); + is( + $doc->find_any('Structure::Unknown'), + '', + "Contains no PPI::Structure::Unknown elements", + ); + is( + $doc->find_any('Statement::Unknown'), + '', + "Contains no PPI::Statement::Unknown elements", + ); + + 1; + } ); -sub unknown_objects { - my $doc = shift; - - is( - $doc->find_any('Token::Unknown'), - '', - "Contains no PPI::Token::Unknown elements", - ); - is( - $doc->find_any('Structure::Unknown'), - '', - "Contains no PPI::Structure::Unknown elements", - ); - is( - $doc->find_any('Statement::Unknown'), - '', - "Contains no PPI::Statement::Unknown elements", - ); - - 1; -} - - - - - ##################################################################### # Are there any invalid nestings? -Test::Object->register( - class => 'PPI::Document', - tests => 1, - code => \&nested_statements, +r( + 'PPI::Document', 1, sub { + my $doc = shift; + + ok( + ! $doc->find_any( sub { + _INSTANCE($_[1], 'PPI::Statement') + and + any { _INSTANCE($_, 'PPI::Statement') } $_[1]->children + } ), + 'Document contains no nested statements', + ); + } ); -sub nested_statements { - my $doc = shift; - - ok( - ! $doc->find_any( sub { - _INSTANCE($_[1], 'PPI::Statement') - and - any { _INSTANCE($_, 'PPI::Statement') } $_[1]->children - } ), - 'Document contains no nested statements', - ); -} - -Test::Object->register( - class => 'PPI::Document', - tests => 1, - code => \&nested_structures, +r( + 'PPI::Document', 1, sub { + my $doc = shift; + + ok( + ! $doc->find_any( sub { + _INSTANCE($_[1], 'PPI::Structure') + and + any { _INSTANCE($_, 'PPI::Structure') } $_[1]->children + } ), + 'Document contains no nested structures', + ); + } ); -sub nested_structures { - my $doc = shift; - - ok( - ! $doc->find_any( sub { - _INSTANCE($_[1], 'PPI::Structure') - and - any { _INSTANCE($_, 'PPI::Structure') } $_[1]->children - } ), - 'Document contains no nested structures', - ); -} - -Test::Object->register( - class => 'PPI::Document', - tests => 1, - code => \&no_attribute_in_attribute, +r( + 'PPI::Document', 1, sub { + my $doc = shift; + + ok( + ! $doc->find_any( sub { + _INSTANCE($_[1], 'PPI::Token::Attribute') + and + ! exists $_[1]->{_attribute} + } ), + 'No ->{_attribute} in PPI::Token::Attributes', + ); + } ); -sub no_attribute_in_attribute { - my $doc = shift; - - ok( - ! $doc->find_any( sub { - _INSTANCE($_[1], 'PPI::Token::Attribute') - and - ! exists $_[1]->{_attribute} - } ), - 'No ->{_attribute} in PPI::Token::Attributes', - ); -} - - - - - ##################################################################### # PPI::Statement Tests -Test::Object->register( - class => 'PPI::Document', - tests => 1, - code => \&valid_compound_type, -); - -sub valid_compound_type { - my $document = shift; +r( + 'PPI::Document', 1, sub { + my $document = shift; my $compound = $document->find('PPI::Statement::Compound') || []; - is( - scalar( grep { not defined $_->type } @$compound ), - 0, 'All compound statements have defined ->type', - ); -} - - - - + is( + scalar( grep { not defined $_->type } @$compound ), + 0, 'All compound statements have defined ->type', + ); + } +); ##################################################################### # Does ->location work properly # As an aside, fixes #23788: PPI::Statement::location() returns undef for C<({})>. -Test::Object->register( - class => 'PPI::Document', - tests => 1, - code => \&defined_location, +r( + 'PPI::Document', 1, sub { + my $document = shift; + my $bad = $document->find( sub { + not defined $_[1]->location + } ); + is( $bad, '', '->location always defined' ); + } ); -sub defined_location { - my $document = shift; - my $bad = $document->find( sub { - not defined $_[1]->location - } ); - is( $bad, '', '->location always defined' ); -} - 1; diff --git a/t/lib/PPI/Test/Run.pm b/t/lib/PPI/Test/Run.pm index 72ce39af..6bb4a9da 100644 --- a/t/lib/PPI/Test/Run.pm +++ b/t/lib/PPI/Test/Run.pm @@ -6,7 +6,6 @@ use PPI::Document; use PPI::Dumper; use Test::More; use Test::Object; -use lib 't/lib'; use PPI::Test::Object; use Helper 'safe_new'; diff --git a/t/ppi_token_quote_double.t b/t/ppi_token_quote_double.t index a94181c2..58f60c9f 100644 --- a/t/ppi_token_quote_double.t +++ b/t/ppi_token_quote_double.t @@ -4,56 +4,52 @@ use lib 't/lib'; use PPI::Test::pragmas; -use Test::More tests => 22 + ($ENV{AUTHOR_TESTING} ? 1 : 0); +use PPI::Test qw( :cmp ); +use Test::More tests => 13 + ($ENV{AUTHOR_TESTING} ? 1 : 0); use PPI (); -use Helper 'safe_new'; +sub t { + { class => shift, content => shift, @_ }; +} -INTERPOLATIONS: { - # Get a set of objects - my $Document = safe_new \<<'END_PERL'; -"no interpolations" -"no \@interpolations" -"has $interpolation" -"has @interpolation" -"has \\@interpolation" -"" # False content to test double-negation scoping -END_PERL - my $strings = $Document->find('Token::Quote::Double'); - is( scalar @{$strings}, 6, 'Found the 6 test strings' ); - is( $strings->[0]->interpolations, '', 'String 1: No interpolations' ); - is( $strings->[1]->interpolations, '', 'String 2: No interpolations' ); - is( $strings->[2]->interpolations, 1, 'String 3: Has interpolations' ); - is( $strings->[3]->interpolations, 1, 'String 4: Has interpolations' ); - is( $strings->[4]->interpolations, 1, 'String 5: Has interpolations' ); - is( $strings->[5]->interpolations, '', 'String 6: No interpolations' ); +sub i { + my ( $code, $interpolations, %args ) = @_; + cmp_element( + $code, + t( 'PPI::Token::Quote::Double', $code, interpolations => $interpolations, %args ) + ); } +sub si { + cmp_element( shift, { class => 'PPI::Token::Quote::Double', simplify => shift } ); +} -SIMPLIFY: { - my $Document = safe_new \<<'END_PERL'; -"no special characters" -"has \"double\" quotes" -"has 'single' quotes" -"has $interpolation" -"has @interpolation" -"" -END_PERL - my $strings = $Document->find('Token::Quote::Double'); - is( scalar @{$strings}, 6, 'Found the 6 test strings' ); - is( $strings->[0]->simplify, q<'no special characters'>, 'String 1: No special characters' ); - is( $strings->[1]->simplify, q<"has \"double\" quotes">, 'String 2: Double quotes' ); - is( $strings->[2]->simplify, q<"has 'single' quotes">, 'String 3: Single quotes' ); - is( $strings->[3]->simplify, q<"has $interpolation">, 'String 3: Has interpolation' ); - is( $strings->[4]->simplify, q<"has @interpolation">, 'String 4: Has interpolation' ); - is( $strings->[5]->simplify, q<''>, 'String 6: Empty string' ); +INTERPOLATIONS: { + i( '"no interpolations"', '' ); + i( '"no \@interpolations"', '' ); + i( '"has $interpolation"', 1 ); + i( '"has @interpolation"', 1 ); + i( '"has \\\\@interpolation"', 1 ); + i( '"" # False content to test double-negation scoping', '', content => '""', STOP => 1 ); } +SIMPLIFY: { + si( '"no special characters"', q<'no special characters'> ); + si( '"has \"double\" quotes"', q<"has \"double\" quotes"> ); + si( '"has \'single\' quotes"', q<"has 'single' quotes"> ); + si( '"has $interpolation"', q<"has $interpolation"> ); + si( '"has @interpolation"', q<"has @interpolation"> ); + si( '""', q<''> ); +} -STRING: { - my $Document = safe_new \'print "foo";'; - my $Double = $Document->find_first('Token::Quote::Double'); - isa_ok( $Double, 'PPI::Token::Quote::Double' ); - is( $Double->string, 'foo', '->string returns as expected' ); +PARSING: { + cmp_selement( + 'print "foo";', + [ + t( "PPI::Token::Word", 'print' ), + t( "PPI::Token::Quote::Double", '"foo"' ), + t( "PPI::Token::Structure", ';' ), + ] + ); }