| File: | blib/lib/App/Test/Generator/Mutation/ConditionalInversion.pm |
| Coverage: | 87.7% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::Test::Generator::Mutation::ConditionalInversion; | |||||
| 2 | ||||||
| 3 | 18 18 18 | 139857 16 188 | use strict; | |||
| 4 | 18 18 18 | 27 21 366 | use warnings; | |||
| 5 | 18 18 18 | 30 15 344 | use Carp qw(croak); | |||
| 6 | 18 18 18 | 67 14 54 | use parent 'App::Test::Generator::Mutation::Base'; | |||
| 7 | 18 18 18 | 700 19 166 | use App::Test::Generator::Mutant; | |||
| 8 | 18 18 18 | 50 18 4146 | use PPI; | |||
| 9 | ||||||
| 10 | our $VERSION = '0.46'; | |||||
| 11 | ||||||
| 12 - 50 | =head1 VERSION
Version 0.46
=head1 METHODS
=head2 applies_to
Returns true if the document contains any C<if> or C<unless> compound
statements that this mutator can target.
=head3 Arguments
=over 4
=item * C<$doc>
A L<PPI::Document> object to inspect.
=back
=head3 Returns
A boolean.
=head3 API specification
=head4 input
{
self => { type => OBJECT, isa => 'App::Test::Generator::Mutation::ConditionalInversion' },
doc => { type => OBJECT, isa => 'PPI::Document' },
}
=head4 output
{ type => SCALAR }
=cut | |||||
| 51 | ||||||
| 52 | sub applies_to { | |||||
| 53 | 68 | 3739 | my ($self, $doc) = @_; | |||
| 54 | 68 | 109 | my $compounds = $doc->find('PPI::Statement::Compound') || []; | |||
| 55 | 68 68 | 88597 133 | for my $stmt (@{$compounds}) { | |||
| 56 | 37 | 61 | my $first = $stmt->schild(0); | |||
| 57 | 37 | 333 | next unless $first && $first->isa('PPI::Token::Word'); | |||
| 58 | 37 | 63 | my $type = $first->content(); | |||
| 59 | 37 | 165 | return 1 if $type eq 'if' || $type eq 'unless'; | |||
| 60 | } | |||||
| 61 | 33 | 89 | return 0; | |||
| 62 | } | |||||
| 63 | ||||||
| 64 - 141 | =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.
Each mutant's optional C<context> field is always set to
C<conditional> (every mutant produced by this strategy targets an
C<if>/C<unless> keyword); its C<line_content> field holds the raw
source text of the mutated line. Both are consumed by
L<App::Test::Generator::Mutator>'s fast-mode dedup.
=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 | |||||
| 142 | ||||||
| 143 | sub mutate { | |||||
| 144 | 53 | 39581 | my ($self, $doc) = @_; | |||
| 145 | ||||||
| 146 | # Find all compound statements in the document | |||||
| 147 | 53 | 91 | my $compounds = $doc->find('PPI::Statement::Compound') || []; | |||
| 148 | 53 | 71371 | my @mutants; | |||
| 149 | ||||||
| 150 | 53 53 | 64 82 | for my $stmt (@{$compounds}) { | |||
| 151 | # Only process if and unless statements | |||||
| 152 | # Use the actual first token content rather than ->type() since | |||||
| 153 | # PPI >= 1.270 returns 'if' for both if and unless via ->type() | |||||
| 154 | 112 | 153 | my $first_word = $stmt->schild(0); | |||
| 155 | 112 | 849 | next unless $first_word && $first_word->isa('PPI::Token::Word'); | |||
| 156 | 112 | 140 | my $type = $first_word->content(); | |||
| 157 | 112 | 281 | next unless $type eq 'if' || $type eq 'unless'; | |||
| 158 | ||||||
| 159 | # Verify the statement has a condition block to invert | |||||
| 160 | 107 441 | 253 814 | my ($cond) = grep { $_->isa('PPI::Structure::Condition') } $stmt->children; | |||
| 161 | 107 | 169 | next unless $cond; | |||
| 162 | ||||||
| 163 | # Capture location for precise targeting in the transform closure | |||||
| 164 | 107 | 163 | my $line = $stmt->location->[0]; | |||
| 165 | 107 | 7478 | my $col = $stmt->location->[1]; | |||
| 166 | ||||||
| 167 | # Determine what the keyword flips to | |||||
| 168 | 107 | 712 | my $flipped = $type eq 'if' ? 'unless' : 'if'; | |||
| 169 | ||||||
| 170 | 107 | 96 | my $mutant = eval { | |||
| 171 | App::Test::Generator::Mutant->new( | |||||
| 172 | id => "COND_INV_${line}_${col}", | |||||
| 173 | group => "COND_INV:$line", | |||||
| 174 | description => "Invert condition $type to $flipped", | |||||
| 175 | line => $line, | |||||
| 176 | type => 'boolean', | |||||
| 177 | original => $cond->content(), | |||||
| 178 | context => 'conditional', | |||||
| 179 | line_content => $self->_line_content($doc, $line), | |||||
| 180 | ||||||
| 181 | # Closure captures line, col and flipped so it targets | |||||
| 182 | # exactly the right statement in the document copy | |||||
| 183 | transform => sub { | |||||
| 184 | 5 | 6 | my ($doc) = @_; | |||
| 185 | 5 | 7 | my $stmts = $doc->find('PPI::Statement::Compound') || []; | |||
| 186 | ||||||
| 187 | 5 5 | 2383 10 | for my $stmt (@{$stmts}) { | |||
| 188 | # Match by line and column to avoid mutating | |||||
| 189 | # the wrong conditional on the same line | |||||
| 190 | 6 | 502 | next unless $stmt->location->[0] == $line; | |||
| 191 | 5 | 1384 | next unless $stmt->location->[1] == $col; | |||
| 192 | ||||||
| 193 | # Flip the leading keyword | |||||
| 194 | 5 | 36 | my $first = $stmt->schild(0); | |||
| 195 | 5 | 34 | next unless $first && $first->isa('PPI::Token::Word'); | |||
| 196 | 5 | 12 | $first->set_content($flipped); | |||
| 197 | 5 | 11 | last; | |||
| 198 | } | |||||
| 199 | }, | |||||
| 200 | 107 | 299 | ); | |||
| 201 | }; | |||||
| 202 | ||||||
| 203 | # Report construction failures clearly rather than silently dropping | |||||
| 204 | 107 | 274 | if($@ || !$mutant) { | |||
| 205 | 2 | 31 | warn "Failed to construct mutant COND_INV_${line}_${col}: $@" if $@; | |||
| 206 | 2 | 5 | next; | |||
| 207 | } | |||||
| 208 | ||||||
| 209 | 105 | 153 | push @mutants, $mutant; | |||
| 210 | } | |||||
| 211 | ||||||
| 212 | 53 | 114 | return @mutants; | |||
| 213 | } | |||||
| 214 | ||||||
| 215 | 1; | |||||