Matplotlib-Simple

 view release on metacpan or  search on metacpan

t/02.interface.t  view on Meta::CPAN

END { unlink grep { defined && -e } @tmp_to_unlink }

sub slurp {
	my ($path) = @_;
	open my $in, '<', $path or die "can't read $path: $!";
	local $/;
	my $t = <$in>;
	close $in;
	return defined $t ? $t : '';
}

# Build a figure with "p" but DON'T run python (execute => 0). "show => 1"
# satisfies plt()'s "need output.file or show" guard without touching the
# output-file machinery, so these structural checks are pure and offline.
# Returns the generated python source text.
sub build_py {
	my (@p) = @_;
	my ( $py, $out, $err );
	( $out, $err, $py ) = capture {
		plt({ p => [@p], show => 1, execute => 0 });
	};
	push @tmp_to_unlink, $py if defined $py;
	return ( defined $py && -e $py ) ? slurp($py) : '';
}

sub count_matches {
	my ( $text, $re ) = @_;
	my $n = 0;
	$n++ while $text =~ /$re/g;
	return $n;
}

# A minimal, valid single-plot spec (hist needs numeric data only).
sub H {
	my ( $key, @vals ) = @_;
	return { 'plot.type' => 'hist', data => { $key => [@vals] } };
}

# ----------------------------------------------------------------------------
# 1. Flat array of hashes  ->  one subplot per hash.
#    p => [ \%h, \%h, \%h ]  ==> 3 subplots on an auto 2x2 grid, one plot each.
# ----------------------------------------------------------------------------
{
	my $py;
	lives_ok {
		$py = build_py( H('A',1,2,3), H('B',2,3,4), H('C',3,4,5) );
	} 'flat p: three single-plot subplots build without dying';

	like( $py, qr/plt\.subplots\(2, 2,/, 'flat p (n=3): auto grid is 2x2' );
	is( count_matches( $py, qr/\bax0\.hist\(/ ), 1, 'flat p: ax0 has 1 plot' );
	is( count_matches( $py, qr/\bax1\.hist\(/ ), 1, 'flat p: ax1 has 1 plot' );
	is( count_matches( $py, qr/\bax2\.hist\(/ ), 1, 'flat p: ax2 has 1 plot' );
	is( count_matches( $py, qr/\.hist\(/ ),      3, 'flat p: 3 plots total (one per subplot)' );
	# the unused 4th cell of the 2x2 grid is pruned by the engine
	like( $py, qr/ax3\.remove\(\)/, 'flat p: empty 4th cell is removed' );
}

# ----------------------------------------------------------------------------
# 2. Mixed forms  ->  an inner array is ONE subplot with overlaid plots.
#    p => [ \%h, [ \%h, \%h ], \%h ]
#    ax1 must carry TWO plot calls (the overlay); ax0 and ax2 one each.
# ----------------------------------------------------------------------------
{
	my $py;
	lives_ok {
		$py = build_py(
			{ 'plot.type' => 'hist', data => { A => [1,2,3] }, title => 'single' },
			[ H('B',1,2), H('C',3,4) ],                      # overlaid in ONE subplot
			{ 'plot.type' => 'hist', data => { D => [5,6] }, title => 'third' },
		);
	} 'mixed p: hash + array-of-hashes + hash builds without dying';

	is( count_matches( $py, qr/\bax0\.hist\(/ ), 1, 'mixed p: ax0 = single plot' );
	is( count_matches( $py, qr/\bax1\.hist\(/ ), 2, 'mixed p: ax1 = two overlaid plots' );
	is( count_matches( $py, qr/\bax2\.hist\(/ ), 1, 'mixed p: ax2 = single plot' );
	is( count_matches( $py, qr/\.hist\(/ ),      4, 'mixed p: 4 plots across 3 subplots' );
	like( $py, qr/ax0\.set_title\('single'\)/, 'mixed p: subplot 0 title preserved' );
	like( $py, qr/ax2\.set_title\('third'\)/,  'mixed p: subplot 2 title preserved' );
}

# ----------------------------------------------------------------------------
# 3. A single-element p is a 1x1 figure.
# ----------------------------------------------------------------------------
{
	my $py = build_py( H('A',1,2,3) );
	like( $py, qr/plt\.subplots\(1, 1,/, 'single p element: 1x1 grid' );
	is( count_matches( $py, qr/\.hist\(/ ), 1, 'single p element: 1 plot' );
}

# ----------------------------------------------------------------------------
# 4. Partial grid: giving one dimension derives the other.
#    ncols => 1 stacks vertically (3x1); nrows => 1 lays out in a row (1x3).
# ----------------------------------------------------------------------------
{
	my ( $py, $out, $err );
	( $out, $err, $py ) = capture {
		plt({ p => [ H('A',1), H('B',2), H('C',3) ], ncols => 1, show => 1, execute => 0 });
	};
	push @tmp_to_unlink, $py if defined $py;
	like( slurp($py), qr/plt\.subplots\(3, 1,/, 'ncols=1 with 3 subplots -> 3x1 (vertical stack)' );
}
{
	my ( $py, $out, $err );
	( $out, $err, $py ) = capture {
		plt({ p => [ H('A',1), H('B',2), H('C',3) ], nrows => 1, show => 1, execute => 0 });
	};
	push @tmp_to_unlink, $py if defined $py;
	like( slurp($py), qr/plt\.subplots\(1, 3,/, 'nrows=1 with 3 subplots -> 1x3 (single row)' );
}

# ----------------------------------------------------------------------------
# 5. Explicit grid is honoured verbatim.
# ----------------------------------------------------------------------------
{
	my ( $py, $out, $err );
	( $out, $err, $py ) = capture {
		plt({ p => [ H('A',1) ], nrows => 2, ncols => 3, show => 1, execute => 0 });
	};
	push @tmp_to_unlink, $py if defined $py;
	like( slurp($py), qr/plt\.subplots\(2, 3,/, 'explicit nrows/ncols kept as given' );
}

# ----------------------------------------------------------------------------
# 6. Error paths: bad "p" shapes and forbidden combinations must die clearly.
# ----------------------------------------------------------------------------
throws_ok { plt({ p => { A => 1 },        show => 1 }) }
	qr/must be an ARRAY reference/,        'p as a HASH ref dies';
throws_ok { plt({ p => [],                show => 1 }) }
	qr/is empty/,                          'empty p dies';
throws_ok { plt({ p => [ [] ],            show => 1 }) }
	qr/empty array/,                       'empty inner subplot array dies';
throws_ok { plt({ p => [ 42 ],            show => 1 }) }
	qr/must be a HASH reference/,          'scalar subplot element dies';
throws_ok { plt({ p => [ [ 42 ] ],        show => 1 }) }
	qr/every plot must be a HASH/,         'non-hash inside an inner array dies';
throws_ok { plt({ p => [ H('A',1) ], data => { X => [1] },     show => 1 }) }
	qr/cannot be combined with/,           '"data" combined with "p" dies';
throws_ok { plt({ p => [ H('A',1) ], 'plot.type' => 'hist',    show => 1 }) }
	qr/cannot be combined with/,           '"plot.type" combined with "p" dies';
throws_ok { plt({ p => [ H('A',1) ], plots => [ H('A',1) ],    show => 1 }) }
	qr/cannot be combined with/,           '"plots" combined with "p" dies';
throws_ok { plt({ p => [ H('A',1) ], add => [ H('A',1) ],      show => 1 }) }
	qr/cannot be combined with/,           '"add" combined with "p" dies';

# ----------------------------------------------------------------------------
# 7. Render layer: drive matplotlib end-to-end and confirm the mixed figure
#    yields a well-formed SVG with exactly three Axes (the overlay shares one).
#    Skipped unless matplotlib >= 3.10.
# ----------------------------------------------------------------------------
SKIP: {
	skip 'matplotlib >= 3.10 not available for the render layer', 4
		unless $mpl_available;

	my $dir = tempdir( CLEANUP => 1 );
	my $svg = File::Spec->catfile( $dir, 'condon.p_interface.svg' );

	my ( $out, $err, $py );
	lives_ok {
		( $out, $err, $py ) = capture {
			plt({
				p => [
					H('A',1,2,2,3,3,3,4),
					[ H('B',1,1,2,2,3), H('C',2,3,3,4,4,5) ],   # overlay -> one subplot
					H('D',5,5,6,6,6,7),
				],
				'output.file' => $svg,
				execute       => 1,
			});
		};
	} 'mixed p figure renders to SVG without dying';
	push @tmp_to_unlink, $py if defined $py;

	ok( -e $svg, 'SVG output file was created' );
	ok( -s $svg, 'SVG output file is non-empty' );

	my $svg_text  = slurp($svg);
	my $axes_seen = count_matches( $svg_text, qr/id="axes_/ );
	is( $axes_seen, 3, 'exactly 3 Axes rendered (overlay collapsed into one subplot)' );
}

done_testing();



( run in 0.520 second using v1.01-cache-2.11-cpan-7fcb06a456a )