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 )