HTML-OSM

 view release on metacpan or  search on metacpan

t/extended_tests.t  view on Meta::CPAN

#!/usr/bin/env perl

# Extended coverage tests for HTML::OSM targeting execution paths that are not
# exercised by the existing suite (function.t / unit.t / edge_cases.t).
# Strategy: identify every conditional branch in OSM.pm, confirm which are
# already hit, and write the smallest subtest that hits each remaining one.
# LCSAJ focus areas:
#   _html_json()          — never tested directly anywhere
#   logger callbacks      — add_marker / center / onload_render all have
#                           logger->error paths that were never exercised
#   all-invalid-coords    — onload_render "center() must be called" croak
#                           distinct from the "No map data provided" croak
#   icon + cluster        — onload_render icon branch with cluster=1
#   geocoder HASH escapes — {lat,lon} missing/partial, falls to carp
#   HTTP HASH response    — Nominatim direct-hash (not array-wrapped) path
#   HTTP no-lat           — 200 OK but response data lacks a lat field
#   HTTP invalid JSON     — 200 OK but body is not JSON (bug fix: now carp+undef)
#   rate-limit sleep      — Time::HiRes::sleep call when min_interval violated
#   key-value API styles  — zoom(zoom=>N) and add_marker(point=>[…]) branches
#   _validate edge cases  — explicit + prefix, whitespace in coord string
#   onload_render misc    — custom width/height, idempotency, height/width fallback
#   _js_string edge cases — standalone CR, Unicode pass-through
#   add_marker tuple      — html AND icon both stored in correct tuple slots
#   GeoJSON popup escaping — popup property name JS-escaped in rendered output

use strict;
use warnings;

use Readonly;
use Scalar::Util qw(blessed);
use Test::Mockingbird qw(mock restore_all);
use Test::Most;
use Test::Returns;
use Time::HiRes qw(time);

BEGIN { use_ok('HTML::OSM') }

Readonly my %C => (
	ZOOM_DEFAULT => 12,
	LAT_LONDON   => 51.5074,
	LON_LONDON   => -0.1278,
	LAT_PARIS    => 48.8566,
	LON_PARIS    =>  2.3522,
);

my $SILENCE = sub { };

# ── Global HTTP block ─────────────────────────────────────────────────────────
# Unique class names (EXT prefix) avoid collisions with other test files.
# NEVER call restore_all() inside a subtest — it removes this block.
{
	my $fail_resp = bless {}, 'EXTNetResp';
	mock 'EXTNetResp::is_success' => sub { 0 };
	my $fail_ua   = bless {}, 'EXTNetUA';
	mock 'EXTNetUA::default_header' => sub { };
	mock 'EXTNetUA::env_proxy'      => sub { };
	mock 'EXTNetUA::get'            => sub { $fail_resp };
	mock 'LWP::UserAgent::new'      => sub { $fail_ua };
}

# ─────────────────────────────────────────────────────────────────────────────
# 1. _html_json() — private function, never directly tested anywhere
#    All JSON embedded in <script> blocks must have </  escaped to <\/
#    to prevent the tag from closing the enclosing <script> element.
# ─────────────────────────────────────────────────────────────────────────────

subtest '_html_json: plain hashref produces valid JSON' => sub {
	my $json = HTML::OSM::_html_json({ color => 'red', weight => 2 });
	like($json, qr/"color"/, 'key present in output');
	like($json, qr/"red"/,   'value present in output');
	returns_ok($json, { type => 'string' }, 'return type is string');
};

subtest '_html_json: </script> in value is escaped to <\\/' => sub {
	# encode_json does NOT escape / by default, so "</script>" would close the
	# enclosing <script> block.  _html_json must post-process it.
	my $json = HTML::OSM::_html_json({ x => '</script>' });
	unlike($json, qr|</script>|,   'raw </script> not present in output');
	like($json,   qr|<\\/script>|, 'escaped form <\\/script> is present');
};

subtest '_html_json: arrayref input encodes to JSON array' => sub {
	my $json = HTML::OSM::_html_json([1, 2, 3]);
	is($json, '[1,2,3]', 'arrayref becomes JSON array literal');

t/extended_tests.t  view on Meta::CPAN

# 17. onload_render() — idempotency
#     The method reads $self state and builds a string; it should not mutate
#     object state in a way that changes the output on subsequent calls.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'onload_render: calling twice produces identical output' => sub {
	my $m = HTML::OSM->new(zoom => 10);
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], html => 'London');
	$m->add_marker([$C{LAT_PARIS},  $C{LON_PARIS}],  html => 'Paris');
	my ($head1, $body1) = $m->onload_render();
	my ($head2, $body2) = $m->onload_render();
	is($head1, $head2, 'head identical on second call (idempotent)');
	is($body1, $body2, 'body identical on second call (idempotent)');
};

# ─────────────────────────────────────────────────────────────────────────────
# 18. _js_string() — standalone CR without LF
#     The regex s/\r?\n/\\n/g requires a LF to match.  A bare \r (carriage
#     return only, as produced by old Mac line endings) is NOT replaced.
#     This documents current behavior; a future hardening pass might change it.
# ─────────────────────────────────────────────────────────────────────────────

subtest '_js_string: standalone CR (without LF) is NOT escaped' => sub {
	my $got = HTML::OSM::_js_string("a\rb");
	is($got, "a\rb", 'standalone \\r passes through unchanged (no LF → no match)');
};

# ─────────────────────────────────────────────────────────────────────────────
# 19. _js_string() — Unicode characters pass through unchanged
#     _js_string escapes backslash, single quote, CRLF/LF, and </script>.
#     It must NOT mangle multi-byte Unicode sequences.
# ─────────────────────────────────────────────────────────────────────────────

subtest '_js_string: Unicode characters pass through without alteration' => sub {
	# U+4E2D U+6587 = "中文" (Chinese). U+00E9 = "é" (Latin-1 supplement).
	my $chinese = "\x{4e2d}\x{6587}";
	my $accented = "caf\x{e9}";
	is(HTML::OSM::_js_string($chinese),  $chinese,  'CJK characters unchanged');
	is(HTML::OSM::_js_string($accented), $accented, 'accented Latin unchanged');
};

# ─────────────────────────────────────────────────────────────────────────────
# 20. add_marker() — html AND icon both provided
#     The coordinate tuple is [lat, lon, label, icon_url].  When both html and
#     icon params are supplied, both must land in the correct slots.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'add_marker: html and icon both stored in correct tuple slots' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker(
		[$C{LAT_LONDON}, $C{LON_LONDON}],
		html => 'London',
		icon => 'https://example.com/pin.png',
	);
	my $t = $m->{coordinates}[0];
	is($t->[2], 'London',                   'html in tuple slot [2]');
	is($t->[3], 'https://example.com/pin.png', 'icon in tuple slot [3]');
};

# ─────────────────────────────────────────────────────────────────────────────
# 21. onload_render() — GeoJSON popup property name JS-escaped
#     The property name passed to popup => '…' is interpolated via _js_string
#     into the onEachFeature callback.  A single quote in the prop name would
#     break the JS string if not escaped.
# ─────────────────────────────────────────────────────────────────────────────

subtest "onload_render: GeoJSON popup property name with single quote is JS-escaped" => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
	$m->add_geojson(
		{ type => 'FeatureCollection', features => [] },
		popup => "o'malley",    # property name containing a single quote
	);
	my (undef, $body) = $m->onload_render();
	unlike($body, qr/properties\['o'malley'\]/, "raw quote not in property name");
	like($body,   qr/o\\'malley/,               "quote escaped in popup property name");
};

# ─────────────────────────────────────────────────────────────────────────────
# 22. onload_render() — choropleth key name JS-escaped in rendered callback
#     The choropleth key (feature property name used for lookup) passes through
#     _js_string before embedding.  A quote in the key would break the JS.
# ─────────────────────────────────────────────────────────────────────────────

subtest "onload_render: choropleth key with single quote is JS-escaped" => sub {
	my $m = HTML::OSM->new();
	$m->center([$C{LAT_LONDON}, $C{LON_LONDON}]);
	$m->add_choropleth(
		[{ type => 'Feature', properties => { "prop'name" => 'X' },
		   geometry => { type => 'Point', coordinates => [0,0] } }],
		{ X => 100 },
		key => "prop'name",
	);
	my (undef, $body) = $m->onload_render();
	like($body, qr/prop\\'name/, "choropleth key with quote is JS-escaped in body");
};

# ─────────────────────────────────────────────────────────────────────────────
# 23. new() — function-style with undef-ish first arg
#     Calling HTML::OSM::new() function-style with no args detects that $class
#     is 'HTML::OSM' (not a blessed ref), proceeds through normal constructor.
#     Calling HTML::OSM::new(zoom => 5) — $class = 'zoom', which is not a
#     blessed ref and does not isa HTML::OSM — triggers the unshift path.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'new: function-style with args uses unshift path correctly' => sub {
	# 'zoom' is the first element: !blessed && !isa HTML::OSM → unshifted back.
	my $m = HTML::OSM::new(zoom => 8);
	isa_ok($m, 'HTML::OSM', 'function-style new with args returns HTML::OSM');
	is($m->zoom(), 8, 'zoom arg respected via function-style call');
};

# ─────────────────────────────────────────────────────────────────────────────
# 24. add_marker() — geo object with NO icon (icon slot is undef in tuple)
#     This confirms the tuple always has 4 slots even when icon is not supplied.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'add_marker: no icon supplied → tuple slot [3] is undef' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], html => 'London');
	is(scalar @{$m->{coordinates}[0]}, 4,     'tuple always has 4 elements');
	ok(!defined $m->{coordinates}[0][3],       'icon slot [3] is undef when not supplied');
};

# ─────────────────────────────────────────────────────────────────────────────
# 25. center() — [lat, lon] with undef elements returns 0 without storing
#     Exercises the `return 0 unless defined($lat) && defined($lon)` guard
#     inside center() for the ARRAY path specifically.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'center: [undef, undef] arrayref returns 0 and does not store center' => sub {
	my $m = HTML::OSM->new();
	is($m->center([undef, undef]), 0, 'returns 0');
	ok(!defined $m->{center}, 'center not stored on failure');
};



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