DateTime-Format-Genealogy
view release on metacpan or search on metacpan
t/integration.t view on Meta::CPAN
#!perl
# Black-box integration tests for DateTime::Format::Genealogy.
#
# These tests exercise stateful, cross-method workflows: constructor flags
# flowing into parse_datetime, per-object cache isolation, cloning chains,
# optional-dependency fallbacks, and concurrent independent instances.
#
# Calling convention: Params::Get does NOT support mixing a positional scalar
# with named key-value flags. All parse_datetime calls that pass flags use
# the named (date => '...', quiet => 1) or hashref form.
use strict;
use warnings;
use Test::Most;
use Test::Returns;
use Readonly;
use Scalar::Util qw(blessed refaddr);
# Test::Without::Module blocks require() at load time; import it before the
# module under test so we can selectively hide optional back-ends.
use Test::Without::Module ();
BEGIN { use_ok('DateTime::Format::Genealogy') || BAIL_OUT('Cannot load module') }
Readonly my $PKG => 'DateTime::Format::Genealogy';
# ---------------------------------------------------------------------------
# Lightweight stubs for optional calendar back-ends.
#
# Neither DateTime::Calendar::Hebrew nor DateTime::Calendar::FrenchRevolutionary
# is installed in this environment, so we inject stubs that behave like the
# real modules. %INC entries prevent require() from searching @INC.
#
# The stubs record how they are constructed so spy-style assertions can verify
# that _convert_calendar passes the right coordinates.
# ---------------------------------------------------------------------------
my @hebrew_new_calls;
my @french_new_calls;
{
package DateTime::Calendar::Hebrew;
sub new {
my ($class, %args) = @_;
push @hebrew_new_calls, \%args;
return bless { %args }, $class;
}
}
$INC{'DateTime/Calendar/Hebrew.pm'} = 1;
{
package DateTime::Calendar::FrenchRevolutionary;
sub new {
my ($class, %args) = @_;
push @french_new_calls, \%args;
return bless { %args }, $class;
}
}
$INC{'DateTime/Calendar/FrenchRevolutionary.pm'} = 1;
# ===========================================================================
# SECTION 1: Multi-instance isolation
#
# Two independent objects must not share mutable state (the all_dates cache
# or the internal DFN/date_parser lazy singletons).
# ===========================================================================
subtest 'two independent objects do not share cache state' => sub {
my $obj_a = $PKG->new();
my $obj_b = $PKG->new();
isnt(refaddr($obj_a), refaddr($obj_b), 'objects are distinct references');
# Populate obj_a's cache.
$obj_a->parse_datetime('25 Dec 2022');
my $cache_a = $obj_a->{all_dates} // {};
# obj_b must have an empty cache (or at least not share obj_a's hashref).
my $cache_b = $obj_b->{all_dates} // {};
isnt(refaddr($cache_a), refaddr($cache_b),
'per-object caches are distinct hashrefs')
if %{$cache_a} && %{$cache_b};
ok(!exists $cache_b->{'25 Dec 2022'},
"obj_b cache does not contain obj_a's parsed entry");
diag('obj_a cache keys: ' . join(', ', keys %{$cache_a}))
if $ENV{TEST_VERBOSE};
};
subtest 'concurrent instances with different flags do not interfere' => sub {
# quiet_obj silences carps; loud_obj emits them; they must not share flags.
my $quiet_obj = $PKG->new(quiet => 1);
my $loud_obj = $PKG->new();
t/integration.t view on Meta::CPAN
my $st = $PKG->new(strict => 1, quiet => 1);
my @ns_range = $ns->parse_datetime('from 1 Jan 2000 to 31 Dec 2000');
is(scalar @ns_range, 2, 'non-strict: from...to... yields 2 elements');
is($ns_range[0]->dmy, '01-01-2000', 'from start correct');
is($ns_range[1]->dmy, '31-12-2000', 'from end correct');
# Strict treats "from ... to ..." as unparseable, returns empty list.
my @st_range = $st->parse_datetime('from 1 Jan 2000 to 31 Dec 2000');
is(scalar @st_range, 0, 'strict: from...to... yields empty list');
};
# ===========================================================================
# SECTION 5: Per-object cache behaviour
#
# The all_dates cache must accelerate repeated identical calls on the same
# object but must not prevent correct parsing of different date strings.
# ===========================================================================
subtest 'cache accelerates repeated calls and yields equal DateTime values' => sub {
my $obj = $PKG->new();
Readonly my $DATE => '5 Jan 2019';
my $first = $obj->parse_datetime($DATE);
my $second = $obj->parse_datetime($DATE);
my $third = $obj->parse_datetime($DATE);
isa_ok($first, 'DateTime', 'first parse returns DateTime');
is($first->dmy, $second->dmy, 'second parse value matches first');
is($second->dmy, $third->dmy, 'third parse value matches second');
# Cache must contain exactly one entry for the key.
my $cache = $obj->{all_dates} // {};
ok(exists $cache->{$DATE}, 'date string is in the cache after first parse');
is(scalar keys %{$cache}, 1, 'only one entry in cache after three identical calls')
if scalar keys %{$cache} > 0;
diag("Cache entry key: '$DATE'") if $ENV{TEST_VERBOSE};
};
subtest 'different date strings each get their own cache entry' => sub {
my $obj = $PKG->new();
Readonly my @DATES => ('25 Dec 2022', '5 Jan 2019', '29 Sep 1939');
for my $date (@DATES) {
$obj->parse_datetime($date);
}
my $cache = $obj->{all_dates} // {};
for my $date (@DATES) {
ok(exists $cache->{$date}, "cache contains entry for '$date'");
}
diag('Cache entries: ' . join(', ', sort keys %{$cache}))
if $ENV{TEST_VERBOSE};
};
# ===========================================================================
# SECTION 6: GEDCOM calendar escapes â end-to-end with stubs installed
# ===========================================================================
subtest '@#DJULIAN@ escape applies correct offset for each century tier' => sub {
my $obj = $PKG->new();
# Offset tiers: <1700 => 10, <1800 => 11, <1900 => 12, >= 1900 => 13
Readonly my %JULIAN_CASES => (
# [ input_day, month, year ] => expected_gregorian_day (same month/year)
'15 Mar 1620' => { day => 25, offset => 10 },
'1 Mar 1750' => { day => 12, offset => 11 },
'1 Mar 1850' => { day => 13, offset => 12 },
'1 Mar 1920' => { day => 14, offset => 13 },
);
while (my ($date_str, $expected) = each %JULIAN_CASES) {
my $dt = $obj->parse_datetime("@#DJULIAN@ $date_str");
isa_ok($dt, 'DateTime', "@#DJULIAN@ $date_str parses to DateTime");
if (defined $dt) {
is($dt->day, $expected->{day},
"$date_str: day advanced by $expected->{offset} days");
}
}
};
subtest '@#DHEBREW@ escape delegates to Hebrew stub and returns converted DateTime' => sub {
# Reset the spy array so only calls from this subtest are counted.
@hebrew_new_calls = ();
# Override DateTime->from_object to return a sentinel year that proves
# _convert_calendar returned the converted object and not the original.
Readonly my $SENTINEL_YEAR => 5783;
no warnings 'redefine';
local *DateTime::from_object = sub {
return DateTime->new(year => $SENTINEL_YEAR, month => 10, day => 1);
};
my $obj = $PKG->new();
my $dt = $obj->parse_datetime('@#DHEBREW@ 9 Oct 2022');
isa_ok($dt, 'DateTime', '@#DHEBREW@ returns a DateTime');
is($dt->year, $SENTINEL_YEAR,
'@#DHEBREW@: from_object result returned, not original Gregorian');
# The stub must have been called with Gregorian coordinates from the
# date string so that _convert_calendar passed the right year/month/day.
is(scalar @hebrew_new_calls, 1, 'Hebrew::new called exactly once');
is($hebrew_new_calls[0]{year}, 2022, 'Hebrew::new passed correct year');
is($hebrew_new_calls[0]{month}, 10, 'Hebrew::new passed correct month');
is($hebrew_new_calls[0]{day}, 9, 'Hebrew::new passed correct day');
diag("Hebrew stub called with: " . join(', ', map { "$_=$hebrew_new_calls[0]{$_}" } qw(year month day)))
if $ENV{TEST_VERBOSE};
};
subtest '@#DFRENCH R@ escape delegates to FrenchRevolutionary stub' => sub {
@french_new_calls = ();
Readonly my $SENTINEL_YEAR => 1792;
no warnings 'redefine';
local *DateTime::from_object = sub {
return DateTime->new(year => $SENTINEL_YEAR, month => 9, day => 22);
};
my $obj = $PKG->new();
my $dt = $obj->parse_datetime('@#DFRENCH R@ 22 Sep 1792');
isa_ok($dt, 'DateTime', '@#DFRENCH R@ returns a DateTime');
is($dt->year, $SENTINEL_YEAR,
'@#DFRENCH R@: from_object result returned');
is(scalar @french_new_calls, 1, 'FrenchRevolutionary::new called exactly once');
diag("French stub called with year=$french_new_calls[0]{year}")
if $ENV{TEST_VERBOSE};
};
# ===========================================================================
# SECTION 7: Optional-dependency fallbacks with Test::Without::Module
#
# When DateTime::Calendar::Hebrew or DateTime::Calendar::FrenchRevolutionary
# is absent, the module must gracefully degrade: carp (unless quiet) and
# return undef rather than dying.
#
# Test::Without::Module removes modules from %INC and blocks their loading,
# so we must run these tests before the stubs registered above could be
# re-used. We localise %INC inside the block to undo the stub injection
# for the duration of each Without::Module scope.
# ===========================================================================
subtest 'graceful degradation without DateTime::Calendar::Hebrew' => sub {
# Temporarily unregister the Hebrew stub so require() fails as if the
# real module is absent.
local %INC = %INC;
delete $INC{'DateTime/Calendar/Hebrew.pm'};
Test::Without::Module->import('DateTime::Calendar::Hebrew');
my $obj = $PKG->new();
# Must carp about conversion failure (not die).
warning_like(
sub { $obj->parse_datetime('@#DHEBREW@ 9 Oct 2022') },
qr/Hebrew calendar conversion failed/,
'missing Hebrew module: parse_datetime carps',
);
# With quiet => 1 the carp must be suppressed and undef returned.
warnings_are(
sub {
my $rc = $obj->parse_datetime(date => '@#DHEBREW@ 9 Oct 2022', quiet => 1);
ok(!defined $rc, 'missing Hebrew module + quiet: returns undef');
},
[],
'missing Hebrew module + quiet: no warnings',
);
Test::Without::Module->unimport('DateTime::Calendar::Hebrew');
# Re-register the stub so later tests can still use it.
$INC{'DateTime/Calendar/Hebrew.pm'} = 1;
};
subtest 'graceful degradation without DateTime::Calendar::FrenchRevolutionary' => sub {
local %INC = %INC;
delete $INC{'DateTime/Calendar/FrenchRevolutionary.pm'};
Test::Without::Module->import('DateTime::Calendar::FrenchRevolutionary');
my $obj = $PKG->new();
warning_like(
sub { $obj->parse_datetime('@#DFRENCH R@ 22 Sep 1792') },
qr/French Republican calendar conversion failed/,
'missing FrenchRevolutionary module: parse_datetime carps',
);
warnings_are(
sub {
my $rc = $obj->parse_datetime(date => '@#DFRENCH R@ 22 Sep 1792', quiet => 1);
ok(!defined $rc, 'missing FrenchRevolutionary + quiet: returns undef');
},
[],
'missing FrenchRevolutionary + quiet: no warnings',
);
Test::Without::Module->unimport('DateTime::Calendar::FrenchRevolutionary');
$INC{'DateTime/Calendar/FrenchRevolutionary.pm'} = 1;
};
subtest 'graceful degradation with both optional calendar modules absent' => sub {
local %INC = %INC;
delete $INC{'DateTime/Calendar/Hebrew.pm'};
delete $INC{'DateTime/Calendar/FrenchRevolutionary.pm'};
Test::Without::Module->import(
'DateTime::Calendar::Hebrew',
'DateTime::Calendar::FrenchRevolutionary',
);
my $obj = $PKG->new(quiet => 1);
# Core functionality must be completely unaffected.
my $dt = $obj->parse_datetime('25 Dec 2022');
isa_ok($dt, 'DateTime', 'core parse works without optional calendar modules');
is($dt->dmy, '25-12-2022', 'correct date value');
my $julian = $obj->parse_datetime('@#DJULIAN@ 15 Mar 1620');
isa_ok($julian, 'DateTime', 'DJULIAN works without optional calendar modules');
is($julian->day, 25, 'Julian offset still applied');
# Both optional escapes must degrade gracefully (no die, no exception).
my $h = $obj->parse_datetime('@#DHEBREW@ 9 Oct 2022');
ok(!defined $h, 'DHEBREW with both absent returns undef (quiet)');
my $f = $obj->parse_datetime('@#DFRENCH R@ 22 Sep 1792');
ok(!defined $f, 'DFRENCH R with both absent returns undef (quiet)');
Test::Without::Module->unimport(
'DateTime::Calendar::Hebrew',
'DateTime::Calendar::FrenchRevolutionary',
);
$INC{'DateTime/Calendar/Hebrew.pm'} = 1;
$INC{'DateTime/Calendar/FrenchRevolutionary.pm'} = 1;
};
# ===========================================================================
# SECTION 8: Class-method and bare-function calling forms
#
# All documented invocation styles must produce the same result regardless
# of whether a constructor was called first.
# ===========================================================================
subtest 'all documented calling forms produce consistent results' => sub {
Readonly my $DATE => '29 Sep 1939';
Readonly my $EXPECT => '29-09-1939';
my $obj = $PKG->new();
my @forms = (
[ sub { $obj->parse_datetime($DATE)->dmy }, 'object + plain string' ],
[ sub { $obj->parse_datetime(date => $DATE)->dmy }, 'object + named arg' ],
[ sub { $obj->parse_datetime({ date => $DATE })->dmy }, 'object + hashref' ],
[ sub { $PKG->parse_datetime($DATE)->dmy }, 'class method + string' ],
[ sub { $PKG->parse_datetime({ date => $DATE })->dmy }, 'class method + hashref' ],
[ sub { DateTime::Format::Genealogy::parse_datetime($DATE)->dmy }, 'bare function' ],
);
for my $pair (@forms) {
my ($code, $label) = @{$pair};
is($code->(), $EXPECT, $label);
}
};
# ===========================================================================
# SECTION 9: Global state integrity across multiple calls
#
# parse_datetime must not clobber $_, $!, or $@ between calls.
# ===========================================================================
subtest 'parse_datetime does not clobber global variables across multiple calls' => sub {
my $obj = $PKG->new(quiet => 1);
# Prime $_ so a clobber would be detectable.
local $_ = 'sentinel';
$obj->parse_datetime('25 Dec 2022');
is($_, 'sentinel', '$_ intact after successful parse');
( run in 1.330 second using v1.01-cache-2.11-cpan-364913b4093 )