| File: | blib/lib/App/Test/Generator/Mutation/ConditionalInversion.pm |
| Coverage: | 81.6% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::Test::Generator::Mutation::ConditionalInversion; | |||||
| 2 | ||||||
| 3 | 9 9 9 | 137687 12 112 | use strict; | |||
| 4 | 9 9 9 | 16 6 166 | use warnings; | |||
| 5 | 9 9 9 | 19 10 162 | use Carp qw(croak); | |||
| 6 | 9 9 9 | 15 7 19 | use parent 'App::Test::Generator::Mutation::Base'; | |||
| 7 | 9 9 9 | 415 15 87 | use App::Test::Generator::Mutant; | |||
| 8 | 9 9 9 | 13 9 2636 | use PPI; | |||
| 9 | ||||||
| 10 | our $VERSION = '0.36'; | |||||
| 11 | ||||||
| 12 - 23 | =head1 VERSION Version 0.36 =head1 METHODS =head2 applies_to Returns true if the document contains any C<if> or C<unless> compound statements that this mutator can target. =cut | |||||
| 24 | ||||||
| 25 | sub applies_to { | |||||
| 26 | 5 | 3711 | my ($self, $doc) = @_; | |||
| 27 | 5 | 7 | my $compounds = $doc->find('PPI::Statement::Compound') || []; | |||
| 28 | 5 5 | 1319 6 | for my $stmt (@{$compounds}) { | |||
| 29 | 2 | 3 | my $first = $stmt->schild(0); | |||
| 30 | 2 | 15 | next unless $first && $first->isa('PPI::Token::Word'); | |||
| 31 | 2 | 3 | my $type = $first->content(); | |||
| 32 | 2 | 9 | return 1 if $type eq 'if' || $type eq 'unless'; | |||
| 33 | } | |||||
| 34 | 3 | 10 | return 0; | |||
| 35 | } | |||||
| 36 | ||||||
| 37 - 108 | =head2 mutate
Walk a PPI document and generate one mutant for each C<if> or C<unless>
statement, inverting the keyword to its opposite. This detects cases where
the test suite does not exercise both branches of a conditional.
my $mutation = App::Test::Generator::Mutation::ConditionalInversion->new;
my $doc = PPI::Document->new(\$source);
my @mutants = $mutation->mutate($doc);
for my $m (@mutants) {
print $m->id, ': ', $m->description, "\n";
}
=head3 Arguments
=over 4
=item * C<$self>
An instance of C<App::Test::Generator::Mutation::ConditionalInversion>.
=item * C<$doc>
A L<PPI::Document> object representing the parsed source file to mutate.
The document is not modified by this method.
=back
=head3 Returns
A list of L<App::Test::Generator::Mutant> objects, one per C<if> or
C<unless> statement found in the document. Returns an empty list if no
qualifying statements are found.
Each mutant carries a C<transform> closure that when called with a fresh
L<PPI::Document> copy will flip the targeted keyword from C<if> to
C<unless> or vice versa, targeting the exact statement by line and column
number.
=head3 Notes
Multiple conditionals on the same source line are each mutated
independently. Mutant IDs include both line and column number to ensure
uniqueness.
=head3 API specification
=head4 input
{
self => {
type => OBJECT,
isa => 'App::Test::Generator::Mutation::ConditionalInversion',
},
doc => {
type => OBJECT,
isa => 'PPI::Document',
},
}
=head4 output
{
type => ARRAYREF,
elements => {
type => OBJECT,
isa => 'App::Test::Generator::Mutant',
},
}
=cut | |||||
| 109 | ||||||
| 110 | sub mutate { | |||||
| 111 | 47 | 34232 | my ($self, $doc) = @_; | |||
| 112 | ||||||
| 113 | # Find all compound statements in the document | |||||
| 114 | 47 | 67 | my $compounds = $doc->find('PPI::Statement::Compound') || []; | |||
| 115 | 47 | 50170 | my @mutants; | |||
| 116 | ||||||
| 117 | 47 47 | 39 68 | for my $stmt (@{$compounds}) { | |||
| 118 | # Only process if and unless statements | |||||
| 119 | # Use the actual first token content rather than ->type() since | |||||
| 120 | # PPI >= 1.270 returns 'if' for both if and unless via ->type() | |||||
| 121 | 84 | 80 | my $first_word = $stmt->schild(0); | |||
| 122 | 84 | 530 | next unless $first_word && $first_word->isa('PPI::Token::Word'); | |||
| 123 | 84 | 83 | my $type = $first_word->content(); | |||
| 124 | 84 | 162 | next unless $type eq 'if' || $type eq 'unless'; | |||
| 125 | ||||||
| 126 | # Verify the statement has a condition block to invert | |||||
| 127 | 81 330 | 99 511 | my ($cond) = grep { $_->isa('PPI::Structure::Condition') } $stmt->children; | |||
| 128 | 81 | 94 | next unless $cond; | |||
| 129 | ||||||
| 130 | # Capture location for precise targeting in the transform closure | |||||
| 131 | 81 | 82 | my $line = $stmt->location->[0]; | |||
| 132 | 81 | 5841 | my $col = $stmt->location->[1]; | |||
| 133 | ||||||
| 134 | # Determine what the keyword flips to | |||||
| 135 | 81 | 514 | my $flipped = $type eq 'if' ? 'unless' : 'if'; | |||
| 136 | ||||||
| 137 | 81 | 52 | my $mutant = eval { | |||
| 138 | App::Test::Generator::Mutant->new( | |||||
| 139 | id => "COND_INV_${line}_${col}", | |||||
| 140 | group => "COND_INV:$line", | |||||
| 141 | description => "Invert condition $type to $flipped", | |||||
| 142 | line => $line, | |||||
| 143 | type => 'boolean', | |||||
| 144 | original => $cond->content(), | |||||
| 145 | ||||||
| 146 | # Closure captures line, col and flipped so it targets | |||||
| 147 | # exactly the right statement in the document copy | |||||
| 148 | transform => sub { | |||||
| 149 | 5 | 5 | my ($doc) = @_; | |||
| 150 | 5 | 6 | my $stmts = $doc->find('PPI::Statement::Compound') || []; | |||
| 151 | ||||||
| 152 | 5 5 | 2376 5 | for my $stmt (@{$stmts}) { | |||
| 153 | # Match by line and column to avoid mutating | |||||
| 154 | # the wrong conditional on the same line | |||||
| 155 | 6 | 501 | next unless $stmt->location->[0] == $line; | |||
| 156 | 5 | 1392 | next unless $stmt->location->[1] == $col; | |||
| 157 | ||||||
| 158 | # Flip the leading keyword | |||||
| 159 | 5 | 34 | my $first = $stmt->schild(0); | |||
| 160 | 5 | 36 | next unless $first && $first->isa('PPI::Token::Word'); | |||
| 161 | 5 | 8 | $first->set_content($flipped); | |||
| 162 | 5 | 13 | last; | |||
| 163 | } | |||||
| 164 | }, | |||||
| 165 | 81 | 145 | ); | |||
| 166 | }; | |||||
| 167 | ||||||
| 168 | # Report construction failures clearly rather than silently dropping | |||||
| 169 | 81 | 123 | if($@ || !$mutant) { | |||
| 170 | 0 | 0 | warn "Failed to construct mutant COND_INV_${line}_${col}: $@" if $@; | |||
| 171 | 0 | 0 | next; | |||
| 172 | } | |||||
| 173 | ||||||
| 174 | 81 | 82 | push @mutants, $mutant; | |||
| 175 | } | |||||
| 176 | ||||||
| 177 | 47 | 85 | return @mutants; | |||
| 178 | } | |||||
| 179 | ||||||
| 180 | 1; | |||||