App-makefilepl2cpanfile

 view release on metacpan or  search on metacpan

t/function.t  view on Meta::CPAN

use strict;
use warnings;

use Test::Most;
use Test::Memory::Cycle;
use Test::Mockingbird;
use File::Temp   qw(tempdir);
use Path::Tiny;
use Readonly;
use YAML::Tiny;

use App::makefilepl2cpanfile;

# Private helpers are called via their fully-qualified names.  No strict-refs
# trick is needed because these are compile-time-known symbol names.
# The leading underscore is a convention; Sub::Private is not enforced.

Readonly my $PKG => 'App::makefilepl2cpanfile';

# Shared fixture: a realistic Makefile.PL string exercising the common cases.
Readonly my $MF_SIMPLE => <<'END_MF';
WriteMakefile(
	PREREQ_PM => {
		'Try::Tiny' => 0,
		'Moo'       => '2.000',   # object system
	},
	TEST_REQUIRES => {
		'Test::More' => 0,
	},
	MIN_PERL_VERSION => '5.010',
);
END_MF

# A minimal deps hashref used to drive _emit without going through parse_prereqs.
my %DEPS_BASIC = (
	runtime => {
		requires => {
			'Moo' => { version => '2.000', comment => undef },
		},
	},
);

# -----------------------------------------------------------------------
# _has_version
# Strategy: cover every distinct input category — undef, empty string,
# numeric zero, string "0", non-zero numeric, and non-numeric strings.
# -----------------------------------------------------------------------
subtest '_has_version — boundary classification' => sub {

	# All of these mean "no minimum required" and must return false.
	ok !App::makefilepl2cpanfile::_has_version(undef),   'undef -> false';
	ok !App::makefilepl2cpanfile::_has_version(''),      'empty string -> false';
	ok !App::makefilepl2cpanfile::_has_version('0'),     'string "0" -> false';
	ok !App::makefilepl2cpanfile::_has_version(0),       'numeric 0 -> false';
	ok !App::makefilepl2cpanfile::_has_version('0.0'),   '"0.0" -> false (numeric zero)';

	# These represent real version constraints.
	ok  App::makefilepl2cpanfile::_has_version('1'),     '"1" -> true';
	ok  App::makefilepl2cpanfile::_has_version('1.0'),   '"1.0" -> true';
	ok  App::makefilepl2cpanfile::_has_version('0.001'), '"0.001" -> true (above zero)';
	ok  App::makefilepl2cpanfile::_has_version('5.010'), '"5.010" -> true (Perl version)';
	ok  App::makefilepl2cpanfile::_has_version('6.64'),  '"6.64" -> true';

	# A non-numeric string is not a version number so looks_like_number returns
	# false; the code then falls through to return 1 (truthy).
	ok  App::makefilepl2cpanfile::_has_version('v1.2.3'),
		'"v1.2.3" -> true (non-numeric treated as constraint)';

	diag 'all _has_version boundary cases pass' if $ENV{TEST_VERBOSE};
};

# -----------------------------------------------------------------------
# _parse_min_perl
# Strategy: test each quoting style (single, double, bare numeric) and
# confirm the function returns undef when the key is absent.
# -----------------------------------------------------------------------
subtest '_parse_min_perl — MIN_PERL_VERSION extraction' => sub {

	is App::makefilepl2cpanfile::_parse_min_perl("MIN_PERL_VERSION => '5.010'"),
		'5.010', 'single-quoted version extracted';

	is App::makefilepl2cpanfile::_parse_min_perl('MIN_PERL_VERSION => "5.036"'),
		'5.036', 'double-quoted version extracted';

t/function.t  view on Meta::CPAN

		"requires 'Moo', '2.000';\n",
		'requires with version, no comment';

	# Phase blocks use a single tab as indentation.
	is App::makefilepl2cpanfile::_fmt_dep(
		'requires', 'Moo', { version => 0, comment => undef }, "\t"
	),
		"\trequires 'Moo';\n",
		'tab indent applied for phase blocks';

	# Inline comment appears after the semicolon, separated by three spaces.
	is App::makefilepl2cpanfile::_fmt_dep(
		'requires', 'Moo', { version => '2.000', comment => 'roles engine' }, ''
	),
		"requires 'Moo', '2.000';   # roles engine\n",
		'version and inline comment both emitted';

	# recommends keyword must be preserved as-is (not silently changed to requires).
	is App::makefilepl2cpanfile::_fmt_dep(
		'recommends', 'Future', { version => '0.33', comment => undef }, ''
	),
		"recommends 'Future', '0.33';\n",
		'recommends keyword emitted correctly';

	is App::makefilepl2cpanfile::_fmt_dep(
		'suggests', 'Log::Any', { version => 0, comment => undef }, "\t"
	),
		"\tsuggests 'Log::Any';\n",
		'suggests keyword with tab indent';

	# An empty string comment must be suppressed — only undef comments are
	# documented in the API, but defensive handling prevents stray ' # ' lines.
	is App::makefilepl2cpanfile::_fmt_dep(
		'requires', 'Foo', { version => 0, comment => '' }, ''
	),
		"requires 'Foo';\n",
		'empty-string comment not emitted';

	# String '0' must not produce a version argument — _has_version treats it as false.
	is App::makefilepl2cpanfile::_fmt_dep(
		'requires', 'Bar', { version => '0', comment => undef }, ''
	),
		"requires 'Bar';\n",
		'string "0" version not emitted';

	diag 'all _fmt_dep formatting variants pass' if $ENV{TEST_VERBOSE};
};

# -----------------------------------------------------------------------
# _extract_pairs
# Strategy: test comment capture, blank/comment line skipping, first-
# occurrence-wins across a single call and across multiple calls, return
# value, and in-place mutation semantics.
# -----------------------------------------------------------------------
subtest '_extract_pairs — block parser' => sub {

	# Basic: one module with version zero, one with an explicit version.
	{
		my %deps;
		App::makefilepl2cpanfile::_extract_pairs(
			"    'Try::Tiny' => 0,\n    'Moo' => '2.000',\n",
			\%deps, 'runtime', 'requires',
		);

		ok  exists $deps{runtime}{requires}{'Try::Tiny'}, 'Try::Tiny extracted';
		is  $deps{runtime}{requires}{'Try::Tiny'}{version}, 0, 'version 0 stored';
		is  $deps{runtime}{requires}{'Try::Tiny'}{comment}, undef, 'no comment stored as undef';

		ok  exists $deps{runtime}{requires}{'Moo'}, 'Moo extracted';
		is  $deps{runtime}{requires}{'Moo'}{version}, '2.000', 'explicit version stored';
	}

	# Inline comment must be captured before the comment text is stripped.
	{
		my %deps;
		App::makefilepl2cpanfile::_extract_pairs(
			"    'Foo::Bar' => 0,   # used in bin/ scripts\n",
			\%deps, 'runtime', 'requires',
		);

		is $deps{runtime}{requires}{'Foo::Bar'}{comment}, 'used in bin/ scripts',
			'inline comment captured verbatim';
	}

	# A fully-commented-out module line must be silently ignored.
	{
		my %deps;
		App::makefilepl2cpanfile::_extract_pairs(
			"    # 'Old::Module' => 0,\n",
			\%deps, 'runtime', 'requires',
		);

		ok !exists $deps{runtime}{requires}{'Old::Module'},
			'fully-commented module line is skipped';
	}

	# Blank lines must not introduce phantom entries.
	{
		my %deps;
		App::makefilepl2cpanfile::_extract_pairs(
			"\n\n    'Real' => 0,\n\n",
			\%deps, 'runtime', 'requires',
		);

		ok  exists $deps{runtime}{requires}{'Real'}, 'real entry found across blank lines';
		is  scalar keys %{ $deps{runtime}{requires} }, 1,
			'no phantom entries from blank lines';
	}

	# First-occurrence-wins within a single block: the first entry for a module
	# must not be overwritten by a subsequent entry in the same block.
	{
		my %deps;
		App::makefilepl2cpanfile::_extract_pairs(
			"    'Dup' => '1.00',   # first\n    'Dup' => '2.00',   # second\n",
			\%deps, 'runtime', 'requires',
		);

		is $deps{runtime}{requires}{'Dup'}{version}, '1.00',
			'first occurrence wins within a block';
		is $deps{runtime}{requires}{'Dup'}{comment}, 'first',
			'first comment retained';
	}

	# First-occurrence-wins across calls: a pre-populated entry in the deps
	# hashref must survive a subsequent _extract_pairs call for the same slot.
	{

t/function.t  view on Meta::CPAN

		my $d = App::makefilepl2cpanfile::parse_prereqs(<<'END_MF');
WriteMakefile(
	PREREQ_PM  => { 'Moo' => 0 },
	META_MERGE => {
		prereqs => {
			runtime => {
				recommends => { 'Moo::Role' => '2.000' },
			},
		},
	},
);
END_MF
		ok exists $d->{runtime}{requires}{'Moo'},
			'PREREQ_PM parsed alongside META_MERGE';
		ok exists $d->{runtime}{recommends}{'Moo::Role'},
			'META_MERGE prereqs recommends extracted';
		is $d->{runtime}{recommends}{'Moo::Role'}{version}, '2.000',
			'META_MERGE version preserved';
	}

	# First-occurrence-wins: the same module appearing in both a simple key
	# and a structured prereqs block must appear only once.
	{
		my $d = App::makefilepl2cpanfile::parse_prereqs(<<'END_MF');
WriteMakefile(
	PREREQ_PM => { 'Dup' => '1.00' },
	prereqs   => { runtime => { requires => { 'Dup' => '2.00' } } },
);
END_MF
		ok exists $d->{runtime}{requires}{'Dup'}, 'Dup present in result';
		# Whichever occurrence was parsed first wins; both are valid.
		ok defined $d->{runtime}{requires}{'Dup'}{version},
			'Dup has exactly one version (first-occurrence-wins)';
	}

	# No memory cycles in the returned data structure.
	{
		my $d = App::makefilepl2cpanfile::parse_prereqs($MF_SIMPLE);
		memory_cycle_ok( $d, 'parse_prereqs return value has no memory cycles' );
	}

	diag 'all parse_prereqs cases pass' if $ENV{TEST_VERBOSE};
};

# -----------------------------------------------------------------------
# generate
# Strategy: use real temp-dir fixtures for filesystem interaction; mock
# File::HomeDir so config loading is deterministic and does not interfere
# with the developer's actual ~/.config file.
# -----------------------------------------------------------------------
subtest 'generate — integration: parse, merge, format' => sub {

	# Redirect config loading to an empty home so default develop tools are
	# injected whenever with_develop => 1 is in effect.  The guard is held for
	# the entire subtest; it restores File::HomeDir when the sub returns.
	my $empty_home = tempdir( CLEANUP => 1 );
	my $g = mock_scoped 'File::HomeDir::my_home' => sub { $empty_home };

	my $dir = tempdir( CLEANUP => 1 );
	my $mf  = path($dir)->child('Makefile.PL');
	$mf->spew_utf8("WriteMakefile(PREREQ_PM => { 'Try::Tiny' => 0 });\n");

	# ---- Guard: croak on missing file ----
	throws_ok {
		App::makefilepl2cpanfile::generate( makefile => "$dir/nonexistent.pl" )
	}
		qr/Cannot read/,
		'croaks with "Cannot read" for missing makefile';

	# ---- Guard: croak when path is a directory, not a file ----
	throws_ok {
		App::makefilepl2cpanfile::generate( makefile => $dir )
	}
		qr/Cannot read/,
		'croaks when path is a directory';

	# ---- Minimal valid Makefile.PL ----
	{
		my $out;
		lives_ok { $out = App::makefilepl2cpanfile::generate( makefile => "$mf", with_develop => 0 ) }
			'lives with a valid minimal Makefile.PL';
		like $out, qr/requires 'Try::Tiny'/, 'module appears in output';
		like $out, qr/\n$/, 'output ends with a newline';
	}

	# ---- Flat-hash calling style ----
	{
		my $r = App::makefilepl2cpanfile::generate( makefile => "$mf", with_develop => 0 );
		like $r, qr/requires 'Try::Tiny'/, 'flat hash calling style works';
	}

	# ---- Hashref calling style ----
	{
		my $r = App::makefilepl2cpanfile::generate(
			{ makefile => "$mf", with_develop => 0 }
		);
		like $r, qr/requires 'Try::Tiny'/, 'hashref calling style works';
	}

	# ---- with_develop defaults to 1 when the argument is omitted ----
	{
		my $r = App::makefilepl2cpanfile::generate( makefile => "$mf" );
		like $r, qr/Perl::Critic/, 'with_develop defaults to 1 — develop block present';
	}

	# ---- with_develop => 0 suppresses the develop block entirely ----
	{
		my $r = App::makefilepl2cpanfile::generate( makefile => "$mf", with_develop => 0 );
		unlike $r, qr/on 'develop'/, 'with_develop => 0 suppresses develop block';
	}

	# ---- Existing cpanfile 'requires' in develop block is merged ----
	{
		my $existing = "on 'develop' => sub {\n  requires 'My::Tool';\n};\n";
		my $r = App::makefilepl2cpanfile::generate(
			makefile => "$mf", existing => $existing, with_develop => 0
		);
		like $r, qr/My::Tool/, 'existing develop requires entry merged';
	}

	# ---- Existing cpanfile 'recommends' in develop block is merged ----
	{
		my $existing = "on 'develop' => sub {\n  recommends 'My::Nice::Tool';\n};\n";
		my $r = App::makefilepl2cpanfile::generate(
			makefile => "$mf", existing => $existing, with_develop => 0
		);
		like $r, qr/My::Nice::Tool/, 'existing develop recommends entry merged';
	}

	# ---- Hand-curated entry must not be overwritten by default injection ----
	# If Perl::Critic already appears in the existing cpanfile with a specific
	# version, the injection step must not add a duplicate entry.
	{
		my $existing = "on 'develop' => sub {\n  requires 'Perl::Critic', '1.140';\n};\n";
		my $r = App::makefilepl2cpanfile::generate(
			makefile => "$mf", existing => $existing, with_develop => 1
		);
		like $r, qr/Perl::Critic.*1\.140|1\.140.*Perl::Critic/,
			'hand-curated Perl::Critic version not overwritten';
		my @hits = ( $r =~ /Perl::Critic/g );
		is scalar @hits, 1, 'Perl::Critic appears exactly once after merge';
	}

	# ---- MIN_PERL_VERSION is emitted when present in the Makefile.PL ----
	{
		$mf->spew_utf8(
			"WriteMakefile(MIN_PERL_VERSION => '5.016', PREREQ_PM => { 'X' => 0 });\n"
		);
		my $r = App::makefilepl2cpanfile::generate( makefile => "$mf", with_develop => 0 );
		like $r, qr/requires 'perl', '5\.016'/, 'MIN_PERL_VERSION emitted';
	}

	# ---- No memory cycles in the generated string ----
	{
		$mf->spew_utf8("WriteMakefile(PREREQ_PM => { 'Y' => 0 });\n");
		my $r = App::makefilepl2cpanfile::generate( makefile => "$mf", with_develop => 0 );
		memory_cycle_ok( \$r, 'generate return value has no memory cycles' );



( run in 2.008 seconds using v1.01-cache-2.11-cpan-6de40a662fe )