File Coverage

File:blib/lib/App/Test/Generator/Mutation/ConditionalInversion.pm
Coverage:86.0%

linestmtbrancondsubtimecode
1package 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
10our $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
52sub 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
143sub 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
2151;