DateTime-Format-Genealogy
view release on metacpan or search on metacpan
t/function.t view on Meta::CPAN
#!perl
use strict;
use warnings;
use Test::Most;
use Test::Mockingbird;
use Test::Returns;
use Test::Memory::Cycle;
use Readonly;
use Scalar::Util qw(blessed refaddr);
# Allow white-box tests to call private/protected subroutines directly.
# Under prove/make test HARNESS_ACTIVE is set and Sub::Private skips its
# enforcer automatically; this covers direct 'perl t/function.t' runs.
$Sub::Private::BYPASS = 1;
BEGIN {
use_ok('DateTime::Format::Genealogy') || BAIL_OUT('Cannot load module');
}
Readonly my $PKG => 'DateTime::Format::Genealogy';
# -----------------------------------------------------------------------
# Inject lightweight stubs for the two optional calendar back-ends so
# the _convert_calendar tests can exercise the real conversion paths
# without requiring the optional distributions to be installed.
# We set %INC entries so that "require DateTime::Calendar::*" inside
# _convert_calendar short-circuits immediately instead of searching @INC.
# -----------------------------------------------------------------------
{
package DateTime::Calendar::Hebrew;
sub new {
my ($class, %args) = @_;
return bless { year => $args{year}, month => $args{month}, day => $args{day} }, $class;
}
}
$INC{'DateTime/Calendar/Hebrew.pm'} = 1;
{
package DateTime::Calendar::FrenchRevolutionary;
sub new {
my ($class, %args) = @_;
return bless { year => $args{year}, month => $args{month}, day => $args{day} }, $class;
}
}
$INC{'DateTime/Calendar/FrenchRevolutionary.pm'} = 1;
# =======================================================================
# SECTION 1: _julian_to_gregorian_offset
#
# Pure arithmetic helper with four fixed tiers. We test every boundary
# year (the first and last year of each tier) plus a representative
# interior value.
# =======================================================================
subtest '_julian_to_gregorian_offset' => sub {
my $fn = \&{"${PKG}::_julian_to_gregorian_offset"};
# tier => [ boundary_years that belong to it ]
Readonly my %TIER => (
10 => [1400, 1699],
11 => [1700, 1799],
12 => [1800, 1899],
13 => [1900, 1920, 2025],
);
while (my ($expected_offset, $years) = each %TIER) {
for my $year (@{$years}) {
is($fn->($year), $expected_offset,
"year $year maps to offset $expected_offset");
}
}
diag('_julian_to_gregorian_offset spot-checks passed') if $ENV{TEST_VERBOSE};
};
# =======================================================================
# SECTION 2: new()
#
# Verify every calling style, flag propagation, and that the object graph
# is free of circular references.
#
# Note: parse_datetime first checks its own call-time parameters for "quiet"
# and "strict", then falls back to the object's stored attributes set at
# construction time. Per-call values always take precedence.
# =======================================================================
subtest 'new() - construction' => sub {
t/function.t view on Meta::CPAN
# and undef on unparseable input.
# =======================================================================
subtest '_date_parser_cached - croak on undef date' => sub {
my $obj = $PKG->new();
# The croak must fire when the date value is explicitly undef, giving
# callers a clear usage message rather than a cryptic deep exception.
# _date_parser_cached now takes a plain positional arg (no Params::Get).
throws_ok(
sub { $obj->_date_parser_cached(undef) },
qr/Usage: _date_parser_cached/,
'croaks with a usage message when date is undef',
);
};
subtest '_date_parser_cached - successful parse' => sub {
my $obj = $PKG->new();
Readonly my $DATE_STR => '25 Dec 2022';
my $result = $obj->_date_parser_cached($DATE_STR);
returns_ok($result, { type => 'hashref' }, 'returns a hashref on success');
# The hashref must carry the keys that parse_datetime relies on
is($result->{canonical}, $DATE_STR, 'canonical key matches input');
is($result->{day}, '25', 'day key correct');
is($result->{month}, 'Dec', 'month key correct');
is($result->{year}, '2022', 'year key correct');
diag('Parsed result: ' . join(', ', map { "$_ => $result->{$_}" } sort keys %{$result}))
if $ENV{TEST_VERBOSE};
};
subtest '_date_parser_cached - result is memoised' => sub {
my $obj = $PKG->new();
Readonly my $DATE_STR => '29 Sep 1939';
my $first = $obj->_date_parser_cached($DATE_STR);
my $second = $obj->_date_parser_cached($DATE_STR);
# Both calls must return the *identical* reference, proving the
# Genealogy::Gedcom::Date parser was not invoked a second time.
is(refaddr($first), refaddr($second),
'second call returns the same cached reference');
memory_cycle_ok($obj, 'object with populated cache is cycle-free');
};
subtest '_date_parser_cached - undef on unparseable input' => sub {
my $obj = $PKG->new(quiet => 1);
# The quiet attribute on $self suppresses the internal carp here.
# Failures are now cached as undef so repeated calls are O(1).
my $result = $obj->_date_parser_cached('not a date xyzzy 99999');
ok(!defined $result, 'returns undef for garbage input');
};
# =======================================================================
# SECTION 4: _convert_calendar
#
# This private function dispatches on a calendar-type string. We test
# each branch: Julian offset arithmetic, Hebrew and French Revolutionary
# stub conversions, and the unknown-type carp path.
#
# BUG NOTE: the original code used "return ..." inside eval{} for the
# Hebrew and French Republican branches. In Perl, "return" inside
# eval{} exits the eval block rather than the enclosing sub, so the
# converted DateTime was silently discarded and the original $dt was
# returned. The tests below assert the *intended* behaviour (return the
# converted DateTime); the corresponding fix is applied to the module.
# =======================================================================
subtest '_convert_calendar - DJULIAN' => sub {
my $fn = \&{"${PKG}::_convert_calendar"};
# 15 Mar 1620 Julian = 25 Mar 1620 Gregorian (pre-1700 offset = 10 days)
my $julian_dt = DateTime->new(year => 1620, month => 3, day => 15);
my $gregorian = $fn->($julian_dt, 'DJULIAN', 0);
isa_ok($gregorian, 'DateTime', 'DJULIAN returns a DateTime');
is($gregorian->year, 1620, 'DJULIAN: year unchanged');
is($gregorian->month, 3, 'DJULIAN: month unchanged');
is($gregorian->day, 25, 'DJULIAN: day advanced by 10-day offset');
# Verify the offset helper is called with the correct year
my $spy = spy("${PKG}::_julian_to_gregorian_offset");
$fn->($julian_dt, 'DJULIAN', 0);
my @calls = $spy->();
is(scalar @calls, 1, '_julian_to_gregorian_offset called exactly once');
is($calls[0][1], 1620, 'offset helper receives the correct year');
restore_all();
diag('DJULIAN result: ' . $gregorian->dmy) if $ENV{TEST_VERBOSE};
};
subtest '_convert_calendar - DHEBREW returns converted DateTime' => sub {
my $fn = \&{"${PKG}::_convert_calendar"};
# Stub DateTime->from_object with a sentinel year so we can distinguish
# "returned the converted object" from "returned the original $dt".
Readonly my $CONVERTED_YEAR => 5783;
mock 'DateTime::from_object' => sub {
return DateTime->new(year => $CONVERTED_YEAR, month => 10, day => 1);
};
my $original = DateTime->new(year => 2022, month => 10, day => 9);
my $result = $fn->($original, 'DHEBREW', 0);
isa_ok($result, 'DateTime', 'DHEBREW returns a DateTime');
is($result->year, $CONVERTED_YEAR,
'DHEBREW returns the converted DateTime, not the original');
restore_all();
diag('DHEBREW converted year: ' . $result->year) if $ENV{TEST_VERBOSE};
};
subtest '_convert_calendar - DFRENCH R returns converted DateTime' => sub {
my $fn = \&{"${PKG}::_convert_calendar"};
# Sentinel: any year that differs from the input year proves conversion ran
Readonly my $SENTINEL_YEAR => 2000;
mock 'DateTime::from_object' => sub {
return DateTime->new(year => $SENTINEL_YEAR, month => 9, day => 22);
};
my $original = DateTime->new(year => 1792, month => 9, day => 22);
my $result = $fn->($original, 'DFRENCH R', 0);
isa_ok($result, 'DateTime', 'DFRENCH R returns a DateTime');
is($result->year, $SENTINEL_YEAR,
'DFRENCH R returns the converted DateTime, not the original');
restore_all();
};
subtest '_convert_calendar - unknown type carps and passes through' => sub {
my $fn = \&{"${PKG}::_convert_calendar"};
my $dt = DateTime->new(year => 2000, month => 6, day => 15);
# An unrecognised calendar type should carp but return $dt unchanged
my $result;
warning_like(
sub { $result = $fn->($dt, 'DROMAN', 0) },
qr/Calendar type DROMAN not supported/,
'unknown calendar type emits a carp',
);
is($result, $dt, 'unknown calendar: original DateTime returned unchanged');
# With quiet set, the carp must be suppressed entirely
my $quiet_result;
warnings_are(
sub { $quiet_result = $fn->($dt, 'DROMAN', 1) },
[],
'quiet flag suppresses carp for unknown calendar',
);
is($quiet_result, $dt, 'quiet + unknown calendar: original DateTime still returned');
};
# =======================================================================
# SECTION 5: parse_datetime - calling conventions
#
# parse_datetime must accept a plain string, a key-value pair, a hashref,
# and work when called as a class method or a bare function.
# =======================================================================
subtest 'parse_datetime - all calling conventions produce the same result' => sub {
Readonly my $DATE_STR => '29 Sep 1939';
Readonly my $EXPECTED => '29-09-1939';
my $obj = $PKG->new();
is($obj->parse_datetime($DATE_STR)->dmy, $EXPECTED, 'object + plain string');
is($obj->parse_datetime(date => $DATE_STR)->dmy, $EXPECTED, 'object + key-value pair');
is($obj->parse_datetime({ date => $DATE_STR })->dmy, $EXPECTED, 'object + hashref');
is($PKG->parse_datetime($DATE_STR)->dmy, $EXPECTED, 'class method + string');
is($PKG->parse_datetime({ date => $DATE_STR })->dmy, $EXPECTED, 'class method + hashref');
is(DateTime::Format::Genealogy::parse_datetime($DATE_STR)->dmy, $EXPECTED, 'bare function call');
# Return type validation
my $dt = $obj->parse_datetime($DATE_STR);
returns_ok($dt, { type => 'object', isa => 'DateTime' }, 'returns a DateTime object');
diag("Parsed '$DATE_STR' => " . $dt->dmy) if $ENV{TEST_VERBOSE};
};
# =======================================================================
# SECTION 6: parse_datetime - croak contract
# =======================================================================
subtest 'parse_datetime - croaks on missing or undef date' => sub {
my $obj = $PKG->new();
# No arguments at all: our guard must fire before Params::Get gets a chance
# to croak with its own message (which Test::Carp cannot intercept because
# Params::Get uses a compile-time-imported croak symbol).
throws_ok(
sub { $obj->parse_datetime() },
qr/^Usage:.*parse_datetime/,
'no args: croaks with module usage message',
);
throws_ok(
sub { $obj->parse_datetime(date => undef) },
qr/^Usage:.*parse_datetime/,
'explicit undef date: croaks with module usage message',
);
};
# =======================================================================
# SECTION 7: parse_datetime - dates that must return undef
# =======================================================================
subtest 'parse_datetime - returns undef for rejected date forms' => sub {
my $obj = $PKG->new();
# Year-only strings carry no day/month precision for genealogical use.
t/function.t view on Meta::CPAN
is(scalar @strict, 0, 'from...to... is ignored when strict => 1 is passed');
};
subtest 'parse_datetime - dash-separated range normalisation' => sub {
my $obj = $PKG->new();
# "date1 - date2" is normalised to "bet date1 and date2"
my @range = $obj->parse_datetime(date => '28 Jul 1914 - 11 Nov 1918', quiet => 1);
is(scalar @range, 2, 'dash range yields 2 elements');
is($range[0]->dmy, '28-07-1914', 'dash range start correct');
is($range[1]->dmy, '11-11-1918', 'dash range end correct');
};
# =======================================================================
# SECTION 11: parse_datetime - month name leniency (non-strict mode)
#
# Without strict, the module accepts long English month names, several
# common abbreviation variants, and a handful of French month names.
# =======================================================================
subtest 'parse_datetime - non-strict month name acceptance' => sub {
my $obj = $PKG->new();
Readonly my %CASES => (
'12 June 2020' => '12-06-2020',
'12 July 2020' => '12-07-2020',
'1 January 2020'=> '01-01-2020',
'1 August 2020' => '01-08-2020',
'27 Sept 1791' => '27-09-1791',
'27 Sept. 1791' => '27-09-1791',
'21 Mai 1681' => '21-05-1681',
'1 Janv 1752' => '01-01-1752',
'8 Juli 1817' => '08-07-1817',
'29-Aug-1938' => '29-08-1938',
);
while (my ($input, $expected) = each %CASES) {
my $dt = $obj->parse_datetime(date => $input);
ok(defined $dt, "non-strict accepts '$input'");
is($dt->dmy, $expected, "'$input' normalised to '$expected'") if defined $dt;
}
};
subtest 'parse_datetime - strict mode enforces 3-letter months' => sub {
my $obj = $PKG->new();
# A valid 3-letter abbreviation must succeed in strict mode
my $dt = $obj->parse_datetime(date => '29 Sep 1939', strict => 1);
isa_ok($dt, 'DateTime', 'strict mode accepts valid 3-letter abbreviation');
is($dt->dmy, '29-09-1939', 'strict: date value correct');
# Long month names are not valid GEDCOM and must be rejected.
# strict and quiet are passed per-call (not inherited from the object).
ok(!defined $obj->parse_datetime(date => '12 June 2020', strict => 1, quiet => 1),
'strict: long month name returns undef');
ok(!defined $obj->parse_datetime(date => '29 Sept. 1939', strict => 1, quiet => 1),
'strict: Sept. abbreviation rejected');
};
# =======================================================================
# SECTION 12: parse_datetime - GEDCOM calendar escapes
# =======================================================================
subtest 'parse_datetime - @#DJULIAN@ escape' => sub {
my $obj = $PKG->new();
# 15 Mar 1620 Julian = 25 Mar 1620 Gregorian (pre-1700, offset = 10)
my $dt = $obj->parse_datetime(date => '@#DJULIAN@ 15 Mar 1620');
isa_ok($dt, 'DateTime', '@#DJULIAN@ produces a DateTime');
is($dt->day, 25, 'DJULIAN: day advanced by offset');
is($dt->month, 3, 'DJULIAN: month unchanged');
is($dt->year, 1620, 'DJULIAN: year unchanged');
diag('@#DJULIAN@ 15 Mar 1620 => ' . $dt->dmy) if $ENV{TEST_VERBOSE};
};
subtest 'parse_datetime - @#DHEBREW@ escape reaches _convert_calendar' => sub {
# Genealogy::Gedcom::Date cannot parse Hebrew month names such as "Tishri",
# so we mock _date_parser_cached to return a plausible Gregorian parse
# result. That lets parse_datetime reach the _convert_calendar call for
# DHEBREW, which we then verify returns the from_object sentinel rather
# than the intermediate Gregorian DateTime.
Readonly my $SENTINEL_YEAR => 5783;
mock "${PKG}::_date_parser_cached" => sub {
return {
canonical => '9 Oct 2022',
day => '9',
month => 'Oct',
year => '2022',
kind => 'Date',
type => 'Gregorian',
};
};
mock 'DateTime::from_object' => sub {
return DateTime->new(year => $SENTINEL_YEAR, month => 10, day => 1);
};
my $obj = $PKG->new();
my $dt = $obj->parse_datetime(date => '@#DHEBREW@ 9 Oct 2022');
isa_ok($dt, 'DateTime', '@#DHEBREW@ produces a DateTime');
is($dt->year, $SENTINEL_YEAR,
'@#DHEBREW@: from_object result returned (not the intermediate Gregorian)');
restore_all();
diag("DHEBREW result year: $SENTINEL_YEAR") if $ENV{TEST_VERBOSE};
};
# =======================================================================
# SECTION 13: parse_datetime - caching and memory
# =======================================================================
subtest 'parse_datetime - repeated calls use the internal cache' => sub {
my $obj = $PKG->new();
Readonly my $DATE_STR => '5 Jan 2019';
my $first = $obj->parse_datetime(date => $DATE_STR);
my $second = $obj->parse_datetime(date => $DATE_STR);
isa_ok($first, 'DateTime', 'first parse returns DateTime');
isa_ok($second, 'DateTime', 'second parse returns DateTime');
is($first->dmy, $second->dmy, 'repeated parse yields identical date value');
memory_cycle_ok($obj, 'object with cache populated is cycle-free');
diag('Cache size after repeated parse: ' . scalar(keys %{$obj->{all_dates}}) . ' entries')
if $ENV{TEST_VERBOSE};
};
# =======================================================================
# SECTION 14: parse_datetime - quiet/strict inherited from object
#
# Flags set at construction time are picked up by subsequent calls to
# parse_datetime without needing to pass them per-call. Per-call values
# override object-level defaults.
# =======================================================================
( run in 2.158 seconds using v1.01-cache-2.11-cpan-364913b4093 )