HTML-D3

 view release on metacpan or  search on metacpan

t/integration.t  view on Meta::CPAN

	my $chart = HTML::D3->new(width => 1_024, height => 768, title => 'Dims');
	my @data  = (['Jan', 10], ['Feb', 20]);

	# Every full-page method must embed the correct SVG dimensions.
	for my $pair (
		['render_bar_chart',               $chart->render_bar_chart(\@data)],
		['render_line_chart',              $chart->render_line_chart(\@data)],
		['render_line_chart_with_tooltips',$chart->render_line_chart_with_tooltips(\@data)],
	) {
		my ($name, $html) = @$pair;
		like($html, qr/svg id="$SVG_ID" width="1024"/,  "$name: width in SVG");
		like($html, qr/height="768"/,                   "$name: height in SVG");
	}

	# Snippet methods use the same object dimensions.
	my $snip = $chart->render_line_chart_snippet(\@data)->{html};
	my $zoom = $chart->render_zoomable_line_chart_snippet(\@data)->{html};
	like($snip, qr/width="1024"/, 'snippet: width in SVG');
	like($snip, qr/height="768"/, 'snippet: height in SVG');
	like($zoom, qr/width="1024"/, 'zoomable: width in SVG');
	like($zoom, qr/height="768"/, 'zoomable: height in SVG');

	# Multi-series methods also propagate dimensions.
	my $ms = $chart->render_multi_series_line_chart_with_tooltips(\@MULTI_DATA);
	like($ms, qr/svg id="$SVG_ID" width="1024"/, 'multi-series: width in SVG');
	like($ms, qr/height="768"/, 'multi-series: height in SVG');
};

# ─────────────────────────────────────────────────────────────────────────────
# 6. Full-page html-tidy validation
#
# Every method that returns a complete HTML5 document must pass html-tidy with
# no errors.  This is the critical end-to-end gate: it exercises the DOCTYPE,
# head, meta tags, D3 script tag, and tooltip JS strings simultaneously.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'all full-page methods produce html-tidy-valid documents' => sub {
	my $chart = HTML::D3->new(title => 'Tidy', width => 800, height => 600);

	html_tidy_ok(
		$chart->render_bar_chart(\@SIMPLE_DATA),
		'render_bar_chart output is valid HTML5',
	);
	html_tidy_ok(
		$chart->render_line_chart(\@SIMPLE_DATA),
		'render_line_chart output is valid HTML5',
	);
	html_tidy_ok(
		$chart->render_line_chart_with_tooltips(\@SIMPLE_DATA),
		'render_line_chart_with_tooltips output is valid HTML5',
	);
	html_tidy_ok(
		$chart->render_multi_series_line_chart_with_tooltips(\@MULTI_DATA),
		'render_multi_series_line_chart_with_tooltips output is valid HTML5',
	);
	html_tidy_ok(
		$chart->render_multi_series_line_chart_with_animated_tooltips(\@MULTI_DATA),
		'render_multi_series_line_chart_with_animated_tooltips output is valid HTML5',
	);
	html_tidy_ok(
		$chart->render_multi_series_line_chart_with_legends(\@MULTI_DATA),
		'render_multi_series_line_chart_with_legends output is valid HTML5',
	);
	html_tidy_ok(
		$chart->render_multi_series_line_chart_with_interactive_legends(\@MULTI_DATA),
		'render_multi_series_line_chart_with_interactive_legends output is valid HTML5',
	);
};

# ─────────────────────────────────────────────────────────────────────────────
# 7. Snippet isolation: fragment methods produce no page-shell
#
# Both snippet methods must not contain any DOCTYPE, <html>, <head>, or <body>
# regardless of the object settings.  They must be safe to splice directly into
# a host layout without corrupting its structure.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'snippet methods produce page-shell-free fragments in all configurations' => sub {
	# Test with non-default dimensions to rule out hardcoded fragments.
	my $chart = HTML::D3->new(width => 1_024, height => 768, title => 'Frag');

	for my $pair (
		['render_line_chart_snippet',
			$chart->render_line_chart_snippet(\@SIMPLE_DATA)->{html}],
		['render_zoomable_line_chart_snippet',
			$chart->render_zoomable_line_chart_snippet(\@SIMPLE_DATA)->{html}],
	) {
		my ($name, $html) = @$pair;
		unlike($html, qr/<!DOCTYPE/i, "$name: no DOCTYPE");
		unlike($html, qr/<html/i,     "$name: no <html> element");
		unlike($html, qr/<head/i,     "$name: no <head> element");
		unlike($html, qr/<body/i,     "$name: no <body> element");
		like($html,   qr/$D3_CDN/,    "$name does NOT link to CDN (caller's responsibility)")
			if 0;   # Snippets deliberately omit the CDN script tag
		unlike($html, qr/\Q$D3_CDN\E/,
			"$name: caller is responsible for loading D3 -- CDN tag absent from fragment");
	}
};

# ─────────────────────────────────────────────────────────────────────────────
# 8. encode_json called exactly once per render invocation (spy)
#
# Every render method must serialise its data exactly once.  Calling encode_json
# more than once per invocation would be redundant; calling it zero times would
# mean the data is hardcoded or ignored.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'encode_json called exactly once per render, with correct data shape' => sub {
	my $chart = HTML::D3->new();

	for my $pair (
		['render_bar_chart',           sub { $chart->render_bar_chart(\@SIMPLE_DATA) }],
		['render_line_chart',          sub { $chart->render_line_chart(\@SIMPLE_DATA) }],
		['render_line_chart_with_tooltips',
			sub { $chart->render_line_chart_with_tooltips(\@SIMPLE_DATA) }],
		['render_line_chart_snippet',
			sub { $chart->render_line_chart_snippet(\@SIMPLE_DATA) }],
		['render_zoomable_line_chart_snippet',
			sub { $chart->render_zoomable_line_chart_snippet(\@SIMPLE_DATA) }],
		['render_multi_series_line_chart_with_tooltips',
			sub { $chart->render_multi_series_line_chart_with_tooltips(\@MULTI_DATA) }],
	) {
		my ($name, $code) = @$pair;
		my $sp = spy('HTML::D3::encode_json');
		$code->();
		my @calls = $sp->();

t/integration.t  view on Meta::CPAN

		is($@, '', 'JSON::PP encodes multi-series data without error');
		like($ms_json, qr/Series A/, 'JSON::PP preserves series name');

		# Extra tooltip data shape (hashref third element).
		my $extra_json = eval {
			JSON::PP->new->encode([
				{ label => 'Jan', value => 100, extra => { Region => 'North' } },
				{ label => 'Feb', value => 200 },
			]);
		};
		is($@, '', 'JSON::PP encodes extra-tooltip data without error');
		like($extra_json, qr/"extra"/, 'JSON::PP preserves extra key');

		diag("JSON::PP version: $JSON::PP::VERSION") if $ENV{TEST_VERBOSE};
		no Test::Without::Module qw(Cpanel::JSON::XS JSON::XS);
	}

	# (b) HTML::D3 with the active JSON backend produces structurally correct output.
	my $chart   = HTML::D3->new(title => 'Backend Test', width => 800, height => 600);
	my $ref_bar  = $chart->render_bar_chart(\@SIMPLE_DATA);
	my $ref_snip = $chart->render_line_chart_snippet(\@SIMPLE_DATA)->{html};

	like($ref_bar,    qr/<!DOCTYPE html>/i,  'active-backend run: bar has DOCTYPE');
	like($ref_bar,    qr/"label":"January"/, 'active-backend run: bar has correct JSON');
	like($ref_snip,   qr/"label":"January"/, 'active-backend run: snippet has correct JSON');
	unlike($ref_snip, qr/<!DOCTYPE/i,        'active-backend run: snippet has no DOCTYPE');

	diag('active JSON backend: ' . ref(JSON::MaybeXS->new)) if $ENV{TEST_VERBOSE};
};

# ─────────────────────────────────────────────────────────────────────────────
# 10. Global variable integrity
#
# Render methods must not clobber $_ (commonly used by callers in loops),
# $@ (exception state checked after eval blocks), or $! (errno).
# ─────────────────────────────────────────────────────────────────────────────

subtest 'render methods do not clobber $_, $@, or $!' => sub {
	my $chart = HTML::D3->new();

	# Preset sentinel values.
	local $_ = 'sentinel';
	local $@ = '';
	# $! is tricky to set reliably; we just check it does not become truthy.

	$chart->render_bar_chart(\@SIMPLE_DATA);
	is($_, 'sentinel', '$_ is unchanged after render_bar_chart');
	is($@, '',         '$@ is unchanged after render_bar_chart');

	$chart->render_line_chart_with_tooltips(\@SIMPLE_DATA);
	is($_, 'sentinel', '$_ is unchanged after render_line_chart_with_tooltips');
	is($@, '',         '$@ is unchanged after render_line_chart_with_tooltips');

	$chart->render_line_chart_snippet(\@SIMPLE_DATA);
	is($_, 'sentinel', '$_ is unchanged after render_line_chart_snippet');
	is($@, '',         '$@ is unchanged after render_line_chart_snippet');

	$chart->render_zoomable_line_chart_snippet(\@SIMPLE_DATA);
	is($_, 'sentinel', '$_ is unchanged after render_zoomable_line_chart_snippet');

	$chart->render_multi_series_line_chart_with_interactive_legends(\@MULTI_DATA);
	is($_, 'sentinel', '$_ is unchanged after render_multi_series_*_with_interactive_legends');
	is($@, '',         '$@ is unchanged after render_multi_series_*_with_interactive_legends');
};

# ─────────────────────────────────────────────────────────────────────────────
# 11. Extra tooltip data round-trip (snippet vs zoomable)
#
# The optional third element [label, value, \%extra] must be serialised
# consistently by both render_line_chart_snippet and
# render_zoomable_line_chart_snippet.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'extra tooltip data serialised consistently across snippet methods' => sub {
	my $chart = HTML::D3->new();

	my $snip_html = $chart->render_line_chart_snippet(\@EXTRA_DATA)->{html};
	my $zoom_html = $chart->render_zoomable_line_chart_snippet(\@EXTRA_DATA)->{html};

	for my $pair (['snippet', $snip_html], ['zoom', $zoom_html]) {
		my ($name, $html) = @$pair;

		like($html, qr/"extra":\{/,
			"$name: extra object present in D3 JSON binding");
		like($html, qr/Region/,
			"$name: extra key 'Region' appears in JSON");
		like($html, qr/North/,
			"$name: extra value 'North' appears in JSON");
		like($html, qr/Object\.entries\(d\.extra\)/,
			"$name: mouseover handler iterates d.extra");

		# Exactly one data point carries extra; the second point (plain pair) must
		# produce no extra key at all.
		my @hits = ($html =~ m{"extra":\{}g);
		is(scalar @hits, 1, "$name: exactly one data point carries the extra object");
	}

	# Both methods must produce the same Region/North pair -- verify textual
	# consistency of the embedded JSON.
	like($snip_html, qr/"Region":"North"/, 'snippet: Region key has correct value');
	like($zoom_html, qr/"Region":"North"/, 'zoom: Region key has correct value');
};

# ─────────────────────────────────────────────────────────────────────────────
# 12. Title propagation throughout the full render pipeline
#
# The object title must appear in every full-page output's <title> tag AND in
# the visible <h1> heading; it must NOT appear in fragment output (no <title>
# or <h1> in a fragment).
# ─────────────────────────────────────────────────────────────────────────────

subtest 'title propagates to page <title> and <h1> but not to fragments' => sub {
	Readonly my $TITLE => 'My Dashboard Title';
	my $chart = HTML::D3->new(title => $TITLE);

	# Full-page methods.
	for my $html (
		$chart->render_bar_chart(\@SIMPLE_DATA),
		$chart->render_line_chart(\@SIMPLE_DATA),
		$chart->render_line_chart_with_tooltips(\@SIMPLE_DATA),
		$chart->render_multi_series_line_chart_with_tooltips(\@MULTI_DATA),
		$chart->render_multi_series_line_chart_with_legends(\@MULTI_DATA),
		$chart->render_multi_series_line_chart_with_interactive_legends(\@MULTI_DATA),
	) {
		like($html, qr/<title>\Q$TITLE\E<\/title>/, 'title appears in <title> tag');
		like($html, qr/\Q$TITLE\E<\/h1>/,           'title appears in visible <h1>');
	}

	# Fragment methods must NOT contain <title> or <h1>.
	for my $html (
		$chart->render_line_chart_snippet(\@SIMPLE_DATA)->{html},
		$chart->render_zoomable_line_chart_snippet(\@SIMPLE_DATA)->{html},
	) {
		unlike($html, qr/<title>/i, 'fragment has no <title> element');
		unlike($html, qr/<h1/i,     'fragment has no <h1> element');
	}
};

# ─────────────────────────────────────────────────────────────────────────────
# 13. D3 CDN link: full-page methods load D3; fragment methods do not
#
# Fragment callers are responsible for loading D3 in their host page.
# Full-page methods must embed the CDN <script> tag in their <head>.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'D3 CDN script loaded by full-page methods, absent from fragments' => sub {
	my $chart = HTML::D3->new();

	for my $html (
		$chart->render_bar_chart(\@SIMPLE_DATA),
		$chart->render_line_chart(\@SIMPLE_DATA),
		$chart->render_line_chart_with_tooltips(\@SIMPLE_DATA),
		$chart->render_multi_series_line_chart_with_tooltips(\@MULTI_DATA),
	) {
		like($html, qr/\Q$D3_CDN\E/, "full-page output embeds D3 CDN script tag");
	}

	for my $html (
		$chart->render_line_chart_snippet(\@SIMPLE_DATA)->{html},
		$chart->render_zoomable_line_chart_snippet(\@SIMPLE_DATA)->{html},
	) {
		unlike($html, qr/\Q$D3_CDN\E/,
			'fragment omits D3 CDN (caller is responsible)');
	}
};

# ─────────────────────────────────────────────────────────────────────────────
# 14. html-tidy safety: </b> must never appear in any render output
#
# The POD formally specifies that all tooltip JS strings use <\/b> (with
# backslash) to satisfy html-tidy.  This cross-method assertion runs the check
# across every method in one sweep.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'no raw </b> tag appears in any render output' => sub {
	my $chart = HTML::D3->new();

	for my $pair (
		['render_bar_chart',
			$chart->render_bar_chart(\@SIMPLE_DATA)],
		['render_line_chart',
			$chart->render_line_chart(\@SIMPLE_DATA)],
		['render_line_chart_with_tooltips',
			$chart->render_line_chart_with_tooltips(\@SIMPLE_DATA)],
		['render_line_chart_snippet',
			$chart->render_line_chart_snippet(\@SIMPLE_DATA)->{html}],
		['render_zoomable_line_chart_snippet',
			$chart->render_zoomable_line_chart_snippet(\@SIMPLE_DATA)->{html}],
		['render_multi_series_line_chart_with_tooltips',
			$chart->render_multi_series_line_chart_with_tooltips(\@MULTI_DATA)],
		['render_multi_series_line_chart_with_animated_tooltips',
			$chart->render_multi_series_line_chart_with_animated_tooltips(\@MULTI_DATA)],
		['render_multi_series_line_chart_with_legends',
			$chart->render_multi_series_line_chart_with_legends(\@MULTI_DATA)],
		['render_multi_series_line_chart_with_interactive_legends',
			$chart->render_multi_series_line_chart_with_interactive_legends(\@MULTI_DATA)],
	) {
		my ($name, $html) = @$pair;
		unlike($html, qr{</b>}, "$name: no raw </b> in output");
	}
};

done_testing();



( run in 1.592 second using v1.01-cache-2.11-cpan-364913b4093 )