| File: | blib/lib/App/Test/Generator/Mutation/ConditionalInversion.pm |
| Coverage: | 86.0% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::Test::Generator::Mutation::ConditionalInversion; | |||||
| 2 | ||||||
| 3 | 13 13 13 | 134910 10 160 | use strict; | |||
| 4 | 13 13 13 | 26 10 287 | use warnings; | |||
| 5 | 13 13 13 | 19 10 233 | use Carp qw(croak); | |||
| 6 | 13 13 13 | 24 9 34 | use parent 'App::Test::Generator::Mutation::Base'; | |||
| 7 | 13 13 13 | 520 15 115 | use App::Test::Generator::Mutant; | |||
| 8 | 13 13 13 | 20 7 2979 | use PPI; | |||
| 9 | ||||||
| 10 | our $VERSION = '0.41'; | |||||
| 11 | ||||||
| 12 - 50 | =head1 VERSION
Version 0.41
=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 | 51 | 3773 | my ($self, $doc) = @_; | |||
| 54 | 51 | 66 | my $compounds = $doc->find('PPI::Statement::Compound') || []; | |||
| 55 | 51 51 | 58441 74 | for my $stmt (@{$compounds}) { | |||
| 56 | 29 | 40 | my $first = $stmt->schild(0); | |||
| 57 | 29 | 410 | next unless $first && $first->isa('PPI::Token::Word'); | |||
| 58 | 29 | 44 | my $type = $first->content(); | |||
| 59 | 29 | 104 | return 1 if $type eq 'if' || $type eq 'unless'; | |||
| 60 | } | |||||
| 61 | 22 | 45 | 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 | 47 | 39387 | my ($self, $doc) = @_; | |||
| 145 | ||||||
| 146 | # Find all compound statements in the document | |||||
| 147 | 47 | 65 | my $compounds = $doc->find('PPI::Statement::Compound') || []; | |||
| 148 | 47 | 44876 | my @mutants; | |||
| 149 | ||||||
| 150 | 47 47 | 44 58 | 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 | 102 | 121 | my $first_word = $stmt->schild(0); | |||
| 155 | 102 | 751 | next unless $first_word && $first_word->isa('PPI::Token::Word'); | |||
| 156 | 102 | 148 | my $type = $first_word->content(); | |||
| 157 | 102 | 225 | next unless $type eq 'if' || $type eq 'unless'; | |||
| 158 | ||||||
| 159 | # Verify the statement has a condition block to invert | |||||
| 160 | 99 405 | 137 685 | my ($cond) = grep { $_->isa('PPI::Structure::Condition') } $stmt->children; | |||
| 161 | 99 | 134 | next unless $cond; | |||
| 162 | ||||||
| 163 | # Capture location for precise targeting in the transform closure | |||||
| 164 | 99 | 110 | my $line = $stmt->location->[0]; | |||
| 165 | 99 | 7084 | my $col = $stmt->location->[1]; | |||
| 166 | ||||||
| 167 | # Determine what the keyword flips to | |||||
| 168 | 99 | 633 | my $flipped = $type eq 'if' ? 'unless' : 'if'; | |||
| 169 | ||||||
| 170 | 99 | 66 | 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 | 5 | my ($doc) = @_; | |||
| 185 | 5 | 9 | my $stmts = $doc->find('PPI::Statement::Compound') || []; | |||
| 186 | ||||||
| 187 | 5 5 | 2272 8 | for my $stmt (@{$stmts}) { | |||
| 188 | # Match by line and column to avoid mutating | |||||
| 189 | # the wrong conditional on the same line | |||||
| 190 | 6 | 497 | next unless $stmt->location->[0] == $line; | |||
| 191 | 5 | 1396 | next unless $stmt->location->[1] == $col; | |||
| 192 | ||||||
| 193 | # Flip the leading keyword | |||||
| 194 | 5 | 35 | my $first = $stmt->schild(0); | |||
| 195 | 5 | 34 | next unless $first && $first->isa('PPI::Token::Word'); | |||
| 196 | 5 | 10 | $first->set_content($flipped); | |||
| 197 | 5 | 10 | last; | |||
| 198 | } | |||||
| 199 | }, | |||||
| 200 | 99 | 210 | ); | |||
| 201 | }; | |||||
| 202 | ||||||
| 203 | # Report construction failures clearly rather than silently dropping | |||||
| 204 | 99 | 1714 | if($@ || !$mutant) { | |||
| 205 | 2 | 11 | warn "Failed to construct mutant COND_INV_${line}_${col}: $@" if $@; | |||
| 206 | 2 | 1301 | next; | |||
| 207 | } | |||||
| 208 | ||||||
| 209 | 97 | 107 | push @mutants, $mutant; | |||
| 210 | } | |||||
| 211 | ||||||
| 212 | 47 | 75 | return @mutants; | |||
| 213 | } | |||||
| 214 | ||||||
| 215 | 1; | |||||