File Coverage

File:blib/lib/App/Test/Generator.pm
Coverage:66.9%

linestmtbrancondsubtimecode
1package App::Test::Generator;
2
3# TODO: Support routines that take more than one unnamed parameter
4
5
3
3
3
170354
2
39
use strict;
6
3
3
3
5
2
54
use warnings;
7
3
3
3
584
17727
5
use autodie qw(:all);
8
9
3
3
3
19361
277
7
use utf8;
10binmode STDOUT, ':utf8';
11binmode STDERR, ':utf8';
12
13
3
3
3
586
1476
6
use open qw(:std :encoding(UTF-8));
14
15
3
3
3
15067
2
64
use Carp qw(carp croak);
16
3
3
3
646
58218
48
use Config::Abstraction;
17
3
3
3
500
5773
69
use Data::Dumper;
18
3
3
3
524
842
68
use Data::Section::Simple;
19
3
3
3
7
2
73
use File::Basename qw(basename);
20
3
3
3
6
1
21
use File::Spec;
21
3
3
3
692
22399
49
use Template;
22
3
3
3
380
2335
72
use YAML::XS qw(LoadFile);
23
24
3
3
3
7
3
1480
use Exporter 'import';
25
26our @EXPORT_OK = qw(generate);
27
28our $VERSION = '0.06';
29
30 - 402
=head1 NAME

App::Test::Generator - Generate fuzz and corpus-driven test harnesses

=head1 SYNOPSIS

From the command line:

  fuzz-harness-generator t/conf/add.conf > t/add_fuzz.t

From Perl:

  use App::Test::Generator qw(generate);

  # Generate to STDOUT
  App::Test::Generator::generate("t/conf/add.conf");

  # Generate directly to a file
  App::Test::Generator::generate('t/conf/add.conf', 't/add_fuzz.t');

=head1 OVERVIEW

This module takes a formal input/output specification for a routine or
method and automatically generates test cases. In effect, it allows you
to easily add comprehensive black-box tests in addition to the more
common white-box tests that are typically written for CPAN modules and other
subroutines.

The generated tests combine:

=over 4

=item * Random fuzzing based on input types

=item * Deterministic edge cases for min/max constraints

=item * Static corpus tests defined in Perl or YAML

=back

This approach strengthens your test suite by probing both expected and
unexpected inputs, helping you to catch boundary errors, invalid data
handling, and regressions without manually writing every case.

=head1 DESCRIPTION

This module implements the logic behind L<fuzz-harness-generator>.
It parses configuration files (fuzz and/or corpus YAML), and
produces a ready-to-run F<.t> test script using L<Test::Most>.

It reads configuration files (Perl C<.conf> with C<our> variables,
and optional YAML corpus files), and generates a L<Test::Most>-based
fuzzing harness combining:

=over 4

=item * Randomized fuzzing of inputs (with edge cases)

=item * Optional static corpus tests from Perl C<%cases> or YAML file (C<yaml_cases> key)

=item * Functional or OO mode (via C<$new>)

=item * Reproducible runs via C<$seed> and configurable iterations via C<$iterations>

=back

=head2 EDGE CASE GENERATION

In addition to purely random fuzz cases, the harness generates
deterministic edge cases for parameters that declare C<min>, C<max> or C<len> in their schema definitions.

For each constraint, three edge cases are added:

=over 4

=item * Just inside the allowable range

This case should succeed, since it lies strictly within the bounds.

=item * Exactly on the boundary

This case should succeed, since it meets the constraint exactly.

=item * Just outside the boundary

This case is annotated with C<_STATUS = 'DIES'> in the corpus and
should cause the harness to fail validation or croak.

=back

Supported constraint types:

=over 4

=item * C<number>, C<integer>

Uses numeric values one below, equal to, and one above the boundary.

=item * C<string>

Uses strings of lengths one below, equal to, and one above the boundary.

=item * C<arrayref>

Uses references to arrays of with the number of elements one below, equal to, and one above the boundary.

=item * C<hashref>

Uses hashes with key counts one below, equal to, and one above the
boundary (C<min> = minimum number of keys, C<max> = maximum number
of keys).

=item * C<memberof> - arrayref of allowed values for a parameter:

    our %input = (
        status => { type => 'string', memberof => [ 'ok', 'error', 'pending' ] },
        level => { type => 'integer', memberof => [ 1, 2, 3 ] },
    );

The generator will automatically create test cases for each allowed value (inside the member list),
and at least one value outside the list (which should die, C<_STATUS = 'DIES'>).
This works for strings, integers, and numbers.

=item * C<boolean> - automatic boundary tests for boolean fields

    our %input = (
        flag => { type => 'boolean' },
    );

The generator will automatically create test cases for 0 and 1; true and false; off and on, and values that should trigger C<_STATUS = 'DIES'>.

=back

These edge cases are inserted automatically, in addition to the random
fuzzing inputs, so each run will reliably probe boundary conditions
without relying solely on randomness.

=head1 CONFIGURATION

The configuration file is either a file that can be read by L<Config::Abstraction> or a B<trusted input> Perl file that should set variables with C<our>.

The documentation here covers the old trusted input style input, but that will go away so you are recommended to use
Config::Abstraction files.
Example: the generator expects your config to use C<our %input>, C<our $function>, etc.

Recognized items:

=over 4

=item * C<%input> - input params with keys => type/optional specs:

When using named parameters
        our %input = (
                name => { type => 'string', optional => 0 },
                age => { type => 'integer', optional => 1 },
        );

Supported basic types used by the fuzzer: C<string>, C<integer>, C<number>, C<boolean>, C<arrayref>, C<hashref>.
(You can add more types; they will default to C<undef> unless extended.)

For routines with one unnamed parameter

  our %input = (
     type => 'string'
  );

Currently, routines with more than one unnamed parameter are not supported.

=item * C<%output> - output param types for Return::Set checking:

        our %output = (
                type => 'string'
        );

If the output hash contains the key _STATUS, and if that key is set to DIES,
the routine should die with the given arguments; otherwise, it should live.
If it's set to WARNS,
the routine should warn with the given arguments

=item * C<$module> - module name (optional).

If omitted, the generator will guess from the config filename:
C<My-Widget.conf> -> C<My::Widget>.

=item * C<$function> - function/method to test (defaults to C<run>).

=item * C<$new> - optional hashref of args to pass to the module's constructor (object mode):

        our $new = { api_key => 'ABC123', verbose => 1 };

To ensure new is called with no arguments, you still need to define new, thus:

  our $new = '';

=item * C<%cases> - optional Perl static corpus, when the output is a simple string (expected => [ args... ]):

Maps the expected output string to the input and _STATUS

  our %cases = (
    'ok'   => {
        input => 'ping',
        status => 'OK',
    'error' =>
        input => '',
        status => 'DIES'
  );

=item * C<$yaml_cases> - optional path to a YAML file with the same shape as C<%cases>.

=item * C<$seed> - optional integer. When provided, the generated C<t/fuzz.t> will call C<srand($seed)> so fuzz runs are reproducible.

=item * C<$iterations> - optional integer controlling how many fuzz iterations to perform (default 50).

=item * C<%edge_cases> - optional hash mapping of extra values to inject:

        # Two named parameters
        our %edge_cases = (
                name => [ '', 'a' x 1024, \"\x{263A}" ],
                age  => [ -1, 0, 99999999 ],
        );

        # Takes a string input
        our %edge_cases (
                'foo', 'bar'
        );

(Values can be strings or numbers; strings will be properly quoted.)
Note that this only works with routines that take named parameters.

=item * C<%type_edge_cases> - optional hash mapping types to arrayrefs of extra values to try for any field of that type:

        our %type_edge_cases = (
                string  => [ '', ' ', "\t", "\n", "\0", 'long' x 1024, chr(0x1F600) ],
                number  => [ 0, 1.0, -1.0, 1e308, -1e308, 1e-308, -1e-308, 'NaN', 'Infinity' ],
                integer => [ 0, 1, -1, 2**31-1, -(2**31), 2**63-1, -(2**63) ],
        );

=item * C<%config> - optional hash of configuration.

The current supported variables are

=over 4

=item * C<test_nuls>, inject NUL bytes into strings (default: 1)

=item * C<test_undef>, test with undefined value (default: 1)

=item * C<dedup>, fuzzing can create duplicate tests, go some way to remove duplicates (default: 1)

=back

=back

=head1 EXAMPLES

=head2 Math::Simple::add()

Functional fuzz + Perl corpus + seed:

  our $module = 'Math::Simple';
  our $function = 'add';
  our %input = ( a => { type => 'integer' }, b => { type => 'integer' } );
  our %output = ( type => 'integer' );
  our %cases = (
    '3'     => [1, 2],
    '0'     => [0, 0],
    '-1'    => [-2, 1],
    '_STATUS:DIES'  => [ 'a', 'b' ],     # non-numeric args should die
    '_STATUS:WARNS' => [ undef, undef ], # undef args should warn
  );
  our $seed = 12345;
  our $iterations = 100;

=head2 Adding YAML file to generate tests

OO fuzz + YAML corpus + edge cases:

        our %input = ( query => { type => 'string' } );
        our %output = ( type => 'string' );
        our $function = 'search';
        our $new = { api_key => 'ABC123' };
        our $yaml_cases = 't/corpus.yml';
        our %edge_cases = ( query => [ '', '     ', '<script>' ] );
        our %type_edge_cases = ( string => [ \"\\0", "\x{FFFD}" ] );
        our $seed = 999;

=head3 YAML Corpus Example (t/corpus.yml)

A YAML mapping of expected -> args array:

        "success":
          - "Alice"
          - 30
        "failure":
          - "Bob"

=head2 Example with arrayref + hashref

  our %input = (
    tags   => { type => 'arrayref', optional => 1 },
    config => { type => 'hashref' },
  );
  our %output = ( type => 'hashref' );

=head2 Example with memberof

  our %input = (
      status => { type => 'string', memberof => [ 'ok', 'error', 'pending' ] },
  );
  our %output = ( type => 'string' );
  our %config = ( test_nuls => 0, test_undef => 1 );

This will generate fuzz cases for 'ok', 'error', 'pending', and one invalid string that should die.

=head2 New format input

Testing L<HTML::Genealogy::Map>:

  ---

  module: HTML::Genealogy::Map
  function: onload_render

  input:
    gedcom:
      type: object
      can: individuals
    geocoder:
      type: object
      can: geocode
    debug:
      type: boolean
      optional: true
    google_key:
      type: string
      optional: true
      min: 39
      max: 39
      matches: "^AIza[0-9A-Za-z_-]{35}$"

  config:
    test_undef: 0

=head1 OUTPUT

By default, writes C<t/fuzz.t>.
The generated test:

=over 4

=item * Seeds RNG (if configured) for reproducible fuzz runs

=item * Uses edge cases (per-field and per-type) with configurable probability

=item * Runs C<$iterations> fuzz cases plus appended edge-case runs

=item * Validates inputs with Params::Get / Params::Validate::Strict

=item * Validates outputs with L<Return::Set>

=item * Runs static C<is(... )> corpus tests from Perl and/or YAML corpus

=back

=head1 NOTES

=over 4

=item * The conf file must use C<our> declarations so variables are visible to the generator via C<require>.

=back

=cut
403
404sub generate
405{
406
3
75076
        my ($conf_file, $outfile) = @_;
407
408        # --- Globals exported by the user's conf (all optional except function maybe) ---
409        # Ensure data don't persist across calls, which would allow
410
3
5
        local our (%input, %output, %config, $module, $function, $new, %cases, $yaml_cases);
411
3
1
        local our ($seed, $iterations);
412
3
3
        local our (%edge_cases, @edge_case_array, %type_edge_cases);
413
414
3
3
        @edge_case_array = ();
415
416
3
3
        if(defined($conf_file)) {
417                # --- Load configuration safely (require so config can use 'our' variables) ---
418                # FIXME:  would be better to use Config::Abstraction, since requiring the user's config could execute arbitrary code
419                # my $abs = $conf_file;
420                # $abs = "./$abs" unless $abs =~ m{^/};
421                # require $abs;
422
423
3
3
                my $config;
424
3
10
                if($config = Config::Abstraction->new(config_dirs => ['.', ''], config_file => $conf_file)) {
425
3
17387
                        $config = $config->all();
426
3
21
                        if(defined($config->{'$module'}) || defined($config->{'our $module'}) || !defined($config->{'module'})) {
427                                # Legacy file format. This will go away.
428                                # TODO: remove this code
429
2
13
                                $config = _load_conf(File::Spec->rel2abs($conf_file));
430                        }
431                }
432
433
3
10
                if($config) {
434
3
2
31
3
                        %input = %{$config->{input}} if(exists($config->{input}));
435
3
5
                        if(exists($config->{output})) {
436
3
3
                                croak("$conf_file: output should be a hash") unless(ref($config->{output}) eq 'HASH');
437
3
3
1
5
                                %output = %{$config->{output}}
438                        }
439
3
0
4
0
                        %config = %{$config->{config}} if(exists($config->{config}));
440
3
2
3
3
                        %cases = %{$config->{cases}} if(exists($config->{cases}));
441
3
0
3
0
                        %edge_cases = %{$config->{edge_cases}} if(exists($config->{edge_cases}));
442
3
0
2
0
                        %type_edge_cases = %{$config->{type_edge_cases}} if(exists($config->{type_edge_cases}));
443
444
3
4
                        $module = $config->{module} if(exists($config->{module}));
445
3
3
                        $function = $config->{function} if(exists($config->{function}));
446
3
2
                        $new = $config->{new} if(exists($config->{new}));
447
3
3
                        $yaml_cases = $config->{yaml_cases} if(exists($config->{yaml_cases}));
448
3
3
                        $seed = $config->{seed} if(exists($config->{seed}));
449
3
3
                        $iterations = $config->{iterations} if(exists($config->{iterations}));
450
451
3
2
3
3
                        @edge_case_array = @{$config->{edge_case_array}} if(exists($config->{edge_case_array}));
452                }
453
3
3
                _validate_config($config);
454        } else {
455
0
0
                croak 'Usage: generate(conf_file [, outfile])';
456        }
457
458        # --- Globals exported by the user's conf (all optional except function maybe) ---
459        # our (%input, %output, %config, $module, $function, $new, %cases, $yaml_cases);
460        # our ($seed, $iterations);
461        # our (%edge_cases, @edge_case_array, %type_edge_cases);
462
463        # sensible defaults
464
2
2
        $function ||= 'run';
465
2
2
        $iterations ||= 50;              # default fuzz runs if not specified
466
2
3
        $seed = undef if defined $seed && $seed eq '';  # treat empty as undef
467
2
4
        $config{'test_nuls'} //= 1;     # By default, test for embedded NULs
468
2
4
        $config{'test_undef'} //= 1;    # By default, see what happens when passed undef
469
2
2
        $config{'dedup'} //= 1; # fuzzing can easily generate repeats, default is to remove duplicates
470
471        # Guess module name from config file if not set
472
2
2
        if (!$module) {
473
0
0
                (my $guess = basename($conf_file)) =~ s/\.(conf|pl|pm|yml|yaml)$//;
474
0
0
                $guess =~ s/-/::/g;
475
0
0
                $module = $guess || 'Unknown::Module';
476        }
477
478        # FIXME:  Always fails with "Can't locate" - either method
479        # eval "require \"$module\"; \"$module\"->import()";
480        # eval { require $module };
481        # if($@) {
482                # carp(__PACKAGE__, ' (', __LINE__, "): $@");
483        # }
484
485        # --- YAML corpus support (yaml_cases is filename string) ---
486
2
9
        my %yaml_corpus_data;
487
2
2
        if (defined $yaml_cases) {
488
2
8
                croak("$yaml_cases file not found") if(!-f $yaml_cases);
489
490
2
4
                my $yaml_data = LoadFile(Encode::decode('utf8', $yaml_cases));
491
2
114
                if ($yaml_data && ref($yaml_data) eq 'HASH') {
492                        # Validate that the corpus inputs are arrayrefs
493                        # e.g: "FooBar":      ["foo_bar"]
494
2
1
                        my $valid_input = 1;
495
2
2
1
2
                        for my $expected (keys %{$yaml_data}) {
496
2
2
                                my $outputs = $yaml_data->{$expected};
497
2
4
                                unless($outputs && (ref $outputs eq 'ARRAY')) {
498
0
0
                                        carp("$yaml_cases: $expected does not point to an array ref, ignoring");
499
0
0
                                        $valid_input = 0;
500                                }
501                        }
502
503
2
4
                        %yaml_corpus_data = %$yaml_data if($valid_input);
504                }
505        }
506
507        # Merge Perl %cases and YAML corpus safely
508        # my %all_cases = (%cases, %yaml_corpus_data);
509
2
2
        my %all_cases = (%yaml_corpus_data, %cases);
510
2
2
        for my $k (keys %yaml_corpus_data) {
511
2
4
                if (exists $cases{$k} && ref($cases{$k}) eq 'ARRAY' && ref($yaml_corpus_data{$k}) eq 'ARRAY') {
512
0
0
0
0
0
0
                        $all_cases{$k} = [ @{$yaml_corpus_data{$k}}, @{$cases{$k}} ];
513                }
514        }
515
516        # --- Helpers for rendering data structures into Perl code for the generated test ---
517
518        sub _load_conf {
519
2
1
                my $file = $_[0];
520
521
2
2
                my $pkg = 'ConfigLoader';
522
523                # eval in a separate package
524                {
525
2
2
                        package ConfigLoader;
526
3
3
3
8
13
126
                        no strict 'refs';
527
2
274
                        do $file or die "Error loading $file: ", ($@ || $!);
528                }
529
530                # Now pull variables from ConfigLoader
531
2
5
                my @vars = qw(
532                        module new edge_cases function input output cases yaml_cases
533                        seed iterations edge_case_array type_edge_cases config
534                );
535
536
2
2
                my %conf;
537
3
3
3
6
1
4117
                no strict 'refs';       # allow symbolic references here
538
2
2
                for my $v (@vars) {
539
26
19
                        if(my $full = "${pkg}::$v") {
540
26
26
13
25
                                if (defined ${$full}) { # scalar
541
8
8
6
11
                                        $conf{$v} = ${$full};
542
18
18
                                } elsif (@{$full}) {    # array
543
2
2
1
3
                                        $conf{$v} = [ @{$full} ];
544
16
19
                                } elsif (%{$full}) {    # hash
545
6
6
1
10
                                        $conf{$v} = { %{$full} };
546                                }
547                        }
548                }
549
550
2
4
                return \%conf;
551        }
552
553        # Input validation for configuration
554        sub _validate_config {
555
3
2
                my $config = $_[0];
556
557
3
2
                for my $key('module', 'function', 'input') {
558
9
16
                        croak "Missing required '$key' specification" unless $config->{$key};
559                }
560
2
2
                croak 'Invalid input specification' unless(ref $config->{input} eq 'HASH');
561
562                # Validate types, constraints, etc.
563
2
2
2
2
                for my $param (keys %{$config->{input}}) {
564
2
2
                        my $spec = $config->{input}{$param};
565
2
1
                        if(ref($spec)) {
566
1
2
                                croak "Invalid type for parameter '$param'" unless _valid_type($spec->{type});
567                        } else {
568
1
1
                                croak "Invalid type $spec for parameter '$param'" unless _valid_type($spec);
569                        }
570                }
571        }
572
573        sub _valid_type
574        {
575
2
2
                my $type = $_[0];
576
577
2
7
                return(($type eq 'string') ||
578                       ($type eq 'boolean') ||
579                       ($type eq 'integer') ||
580                       ($type eq 'number') ||
581                       ($type eq 'float') ||
582                       ($type eq 'object'));
583      }
584
585        sub perl_sq {
586
21
11
                my $s = $_[0];
587
21
21
21
21
21
12
10
12
11
10
                $s =~ s/\\/\\\\/g; $s =~ s/'/\\'/g; $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; $s =~ s/\t/\\t/g;
588
21
28
                return $s;
589        }
590
591        sub perl_quote {
592
22
10
                my $v = $_[0];
593
22
15
                return 'undef' unless defined $v;
594
21
15
                if(ref($v) eq 'ARRAY') {
595
0
0
0
0
0
0
                        my @quoted_v = map { perl_quote($_) } @{$v};
596
0
0
                        return '[ ' . join(', ', @quoted_v) . ' ]';
597                }
598
21
14
                return Dumper($v) if(ref($v) && (ref($v) ne 'Regexp')); # Generic fallback
599
21
12
                $v =~ s/\\/\\\\/g;
600                # return $v =~ /^-?\d+(\.\d+)?$/ ? $v : "'" . ( $v =~ s/'/\\'/gr ) . "'";
601
21
24
                return $v =~ /^-?\d+(\.\d+)?$/ ? $v : "'" . perl_sq($v) . "'";
602        }
603
604        sub render_hash {
605
2
1
                my $href = $_[0];
606
2
6
                return '' unless $href && ref($href) eq 'HASH';
607
2
1
                my @lines;
608
2
4
                for my $k (sort keys %$href) {
609
2
3
                        my $def = $href->{$k} || {};
610
2
2
                        next unless ref $def eq 'HASH';
611
1
6
                        my @pairs;
612
1
2
                        for my $subk (sort keys %$def) {
613
1
1
                                next unless defined $def->{$subk};
614
1
1
                                if(ref($def->{$subk})) {
615
0
0
                                        unless((ref($def->{$subk}) eq 'ARRAY') || (ref($def->{$subk}) eq 'Regexp')) {
616
0
0
                                                croak(__PACKAGE__, ": conf_file, $subk is a nested element, not yet supported (", ref($def->{$subk}), ')');
617                                        }
618                                }
619
1
2
                                push @pairs, "$subk => " . perl_quote($def->{$subk});
620                        }
621
1
1
                        push @lines, '      ' . perl_quote($k) . " => { " . join(", ", @pairs) . " }";
622                }
623
2
3
                return join(",\n", @lines);
624        }
625
626        sub render_args_hash {
627
2
1
                my $href = $_[0];
628
2
3
                return '' unless $href && ref($href) eq 'HASH';
629
2
2
2
2
                my @pairs = map { perl_quote($_) . ' => ' . perl_quote($href->{$_}) } sort keys %$href;
630
2
2
                return join(', ', @pairs);
631        }
632
633        sub render_arrayref_map {
634
4
3
                my $href = $_[0];
635
4
6
                return '()' unless $href && ref($href) eq 'HASH';
636
4
2
                my @entries;
637
4
4
                for my $k (sort keys %$href) {
638
0
0
                        my $aref = $href->{$k};
639
0
0
                        next unless ref $aref eq 'ARRAY';
640
0
0
0
0
                        my $vals = join(', ', map { perl_quote($_) } @$aref);
641
0
0
                        push @entries, '    ' . perl_quote($k) . " => [ $vals ]";
642                }
643
4
6
                return join(",\n", @entries);
644        }
645
646        # Robustly quote a string (GitHub#1)
647        sub q_wrap {
648
10
4
                my $s = $_[0];
649
10
15
                for my $p ( ['{','}'], ['(',')'], ['[',']'], ['<','>'] ) {
650
10
9
                        my ($l,$r) = @$p;
651
10
39
                        return "q$l$s$r" unless $s =~ /\Q$l\E|\Q$r\E/;
652                }
653
0
                for my $d ('~', '!', '%', '^', '=', '+', ':', ',', ';', '|', '/', '#') {
654
0
                        return "q$d$s$d" unless index($s, $d) >= 0;
655                }
656
0
                (my $esc = $s) =~ s/'/\\'/g;
657
0
                return "'$esc'";
658        }
659
660        # render edge case maps for inclusion in the .t
661
2
4
        my $edge_cases_code = render_arrayref_map(\%edge_cases);
662
2
1
        my $type_edge_cases_code = render_arrayref_map(\%type_edge_cases);
663
664
2
1
        my $edge_case_array_code = '';
665
2
2
        if(scalar(@edge_case_array)) {
666
2
6
2
5
                $edge_case_array_code = join(', ', map { q_wrap($_) } @edge_case_array);
667        }
668
669        # Render configuration
670
2
2
        my $config_code = '';
671
2
4
        foreach my $key (sort keys %config) {
672
6
5
                $config_code .= "'$key' => '$config{$key}',\n";
673        }
674
675        # Render input/output
676
2
2
        my $input_code = '';
677
2
5
        if(((scalar keys %input) == 1) && exists($input{'type'}) && !ref($input{'type'})) {
678                # our %input = ( type => 'string' );
679
0
0
                foreach my $key (sort keys %input) {
680
0
0
                        $input_code .= "'$key' => '$input{$key}',\n";
681                }
682        } else {
683                # our %input = ( str => { type => 'string' } );
684
2
1
                $input_code = render_hash(\%input);
685        }
686
2
2
        my $output_code = render_args_hash(\%output);
687
2
5
        my $new_code = ($new && (ref $new eq 'HASH')) ? render_args_hash($new) : '';
688
689        # Setup / call code (always load module)
690
2
2
        my $setup_code = "BEGIN { use_ok('$module') }";
691
2
1
        my $call_code;
692
2
2
        if(defined($new)) {
693                # keep use_ok regardless (user found earlier issue)
694
0
0
                if($new_code eq '') {
695
0
0
                        $setup_code .= "\nmy \$obj = new_ok('$module');";
696                } else {
697
0
0
                        $setup_code .= "\nmy \$obj = new_ok('$module' => [ { $new_code } ] );";
698                }
699
0
0
                $call_code = "\$result = \$obj->$function(\$input);";
700        } else {
701
2
2
                $call_code = "\$result = $module\->$function(\$input);";
702        }
703
704        # Build static corpus code
705
2
1
        my $corpus_code = '';
706
2
1
        if (%all_cases) {
707
2
2
                $corpus_code = "\n# --- Static Corpus Tests ---\n";
708
2
4
                for my $expected (sort keys %all_cases) {
709
4
4
                        my $inputs = $all_cases{$expected};
710
4
5
                        next unless($inputs && (ref $inputs eq 'ARRAY'));
711
712
4
6
                        my $expected_str = perl_quote($expected);
713
4
5
                        my $status = ((ref($inputs) eq 'HASH') && $inputs->{'_STATUS'}) // 'OK';
714
4
5
                        if($expected_str eq "'_STATUS:DIES'") {
715
0
0
                                $status = 'DIES';
716                        } elsif($expected_str eq "'_STATUS:WARNS'") {
717
0
0
                                $status = 'WARNS';
718                        }
719
720
4
1
                        if(ref($inputs) eq 'HASH') {
721
0
0
                                $inputs = $inputs->{'input'};
722                        }
723
4
8
6
5
                        my $input_str = join(', ', map { perl_quote($_) } @$inputs);
724
4
4
                        if ($new) {
725
0
0
                                if($status eq 'DIES') {
726                                        $corpus_code .= "dies_ok { \$obj->$function($input_str) } " .
727
0
0
0
0
                                                        "'$function(" . join(", ", map { $_ // '' } @$inputs ) . ") dies';\n";
728                                } elsif($status eq 'WARNS') {
729                                        $corpus_code .= "warnings_exist { \$obj->$function($input_str) } qr/./, " .
730
0
0
0
0
                                                        "'$function(" . join(", ", map { $_ // '' } @$inputs ) . ") warns';\n";
731                                } else {
732                                        my $desc = sprintf("$function(%s) returns %s",
733
0
0
0
0
                                                perl_quote(join(', ', map { $_ // '' } @$inputs )),
734                                                $expected_str
735                                        );
736
0
0
                                        $corpus_code .= "is(\$obj->$function($input_str), $expected_str, " . q_wrap($desc) . ");\n";
737                                }
738                        } else {
739
4
4
                                if($status eq 'DIES') {
740                                        $corpus_code .= "dies_ok { $module\::$function($input_str) } " .
741
0
0
0
0
                                                "'$function(" . join(", ", map { $_ // '' } @$inputs ) . ") dies';\n";
742                                } elsif($status eq 'WARNS') {
743                                        $corpus_code .= "warnings_exist { $module\::$function($input_str) } qr/./, " .
744
0
0
0
0
                                                "'$function(" . join(", ", map { $_ // '' } @$inputs ) . ") warns';\n";
745                                } else {
746                                        my $desc = sprintf("$function(%s) returns %s",
747
4
8
3
10
                                                perl_quote(join(', ', map { $_ // '' } @$inputs )),
748                                                $expected_str
749                                        );
750
4
6
                                        $corpus_code .= "is($module\::$function($input_str), $expected_str, " . q_wrap($desc) . ");\n";
751                                }
752                        }
753                }
754        }
755
756        # Prepare seed/iterations code fragment for the generated test
757
2
3
        my $seed_code = '';
758
2
2
        if (defined $seed) {
759                # ensure integer-ish
760
0
0
                $seed = int($seed);
761
0
0
                $seed_code = "srand($seed);\n";
762        }
763        # Generate the test content
764
2
6
        my $tt = Template->new({ ENCODING => 'utf8', TRIM => 1 });
765
766        # Read template from DATA handle
767
2
9481
        my $template = Data::Section::Simple::get_data_section('test.tt');
768
769
2
1427
        my $vars = {
770                setup_code => $setup_code,
771                edge_cases_code => $edge_cases_code,
772                edge_case_array_code => $edge_case_array_code,
773                type_edge_cases_code => $type_edge_cases_code,
774                config_code => $config_code,
775                seed_code => $seed_code,
776                input_code => $input_code,
777                output_code => $output_code,
778                corpus_code => $corpus_code,
779                call_code => $call_code,
780                function => $function,
781                iterations_code => int($iterations),
782                module => $module
783        };
784
785
2
2
        my $test;
786
2
2
        $tt->process(\$template, $vars, \$test) or die $tt->error();
787
788
2
23367
        if ($outfile) {
789
2
4
                open my $fh, '>:encoding(UTF-8)', $outfile or die "Cannot open $outfile: $!";
790
2
1110
                print $fh $test;
791
2
3
                close $fh;
792
2
442
                print "Generated $outfile for $module\::$function with fuzzing + corpus support\n";
793        } else {
794
0
0
                print $test;
795        }
796}
797
7981;
799
800 - 818
=head1 SEE ALSO

=over 4

=item * Test coverage report: L<https://nigelhorne.github.io/App-Test-Generator/coverage/>

=item * L<Test::Most>, L<Params::Get>, L<Params::Validate::Strict>, L<Return::Set>, L<YAML::XS>

=back

=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

Portions of this module's design and documentation were created with the
assistance of L<ChatGPT|https://openai.com/> (GPT-5), with final curation
and authorship by Nigel Horne.

=cut
819