Files
lore-engine-poc-v3/tests/test_time_model.py

152 lines
7.0 KiB
Python

"""Test suite for ``lore_engine_poc.time_model`` (slice 1, sub-slice 1.6).
These cases cover the AC 1.3 contract: ``time_in_window`` is the
load-bearing primitive; its behaviour must be locked down before
slice 2's consistency engine trusts it. The original 13 inline
self-tests are preserved verbatim; the additional cases cover
month/day precision, ``current`` token resolution, null-bound
semantics, and malformed input handling — all the cases called out
in the slice 1 plan.
"""
from __future__ import annotations
import pytest
from lore_engine_poc.time_model import (
CURRENT,
NULL_TOKENS,
is_descendant,
normalize,
time_in_window,
)
# ---------------------------------------------------------------------------
# Original 13 cases from the slice 0 self-test block.
# ---------------------------------------------------------------------------
ORIGINAL_CASES = [
# (at, lo, hi, current, expected, description)
("3rd_age.year_345", "3rd_age.year_340", "3rd_age.year_352", None, True, "year inside year window"),
("3rd_age.year_352", "3rd_age.year_340", "3rd_age.year_352", None, False, "year at exclusive upper"),
("3rd_age.year_340", "3rd_age.year_340", "3rd_age.year_352", None, True, "year at inclusive lower"),
("3rd_age", "3rd_age.year_1", "3rd_age.year_999", None, True, "era ancestor of lo"),
("3rd_age.year_5", "3rd_age", "3rd_age.year_10", None, True, "at is descendant of lo"),
("3rd_age.age_of_iron.year_3", "3rd_age.age_of_iron.year_1", "3rd_age.age_of_iron.year_5", None, True, "sub-era window"),
("3rd_age.age_of_iron.year_6", "3rd_age.age_of_iron.year_1", "3rd_age.age_of_iron.year_5", None, False, "sub-era past upper"),
(None, "3rd_age.year_1", "3rd_age.year_10", None, False, "open at, bounded"),
("3rd_age.year_5", None, "3rd_age.year_10", None, True, "open lower"),
("3rd_age.year_50", "3rd_age.year_10", None, None, True, "open upper"),
("current", "3rd_age.year_300", "3rd_age.year_400", "3rd_age.year_345", True, "current inside"),
("current", "3rd_age.year_300", "3rd_age.year_400", "3rd_age.year_500", False, "current outside"),
("2nd_age.year_5", "3rd_age", "4th_age", None, False, "different era"),
]
# ---------------------------------------------------------------------------
# Additional cases (slice 1, AC 1.3). The plan calls for ≥30 total; we ship
# 13 + 22 = 35.
# ---------------------------------------------------------------------------
ADDITIONAL_CASES = [
# Month/day precision — slice 1 plan §"month/day precision".
("3rd_age.year_345.month_3", "3rd_age.year_345", "3rd_age.year_346", None, True, "month inside year window"),
("3rd_age.year_345.month_3.day_17", "3rd_age.year_345", "3rd_age.year_346", None, True, "day inside year window"),
("3rd_age.year_345.month_4.day_1", "3rd_age.year_345", "3rd_age.year_346", None, True, "next-month day inside year"),
("3rd_age.year_345.month_3.day_17", "3rd_age.year_345.month_3.day_1", "3rd_age.year_345.month_3.day_31", None, True, "day inside day-window"),
# Era-boundary cases.
("3rd_age.age_of_iron.year_1", "3rd_age.age_of_iron.year_1", "3rd_age.age_of_iron.year_5", None, True, "first year of sub-era inclusive"),
("3rd_age.age_of_iron.year_5", "3rd_age.age_of_iron.year_1", "3rd_age.age_of_iron.year_5", None, False, "last year of sub-era exclusive"),
# ``current`` token resolution.
("current", "current", "3rd_age.year_400", "3rd_age.year_345", True, "current as lower bound resolved"),
("3rd_age.year_500", "3rd_age.year_300", "current", "3rd_age.year_345", False, "fixed at past current bound"),
# Null-bound semantics.
(None, None, None, None, True, "all three null"),
("3rd_age.year_5", None, None, None, True, "at present no bounds"),
(None, "3rd_age.year_1", "3rd_age.year_10", None, False, "open at with bounded window"),
("3rd_age.year_5", "3rd_age.year_1", None, None, True, "open upper with at inside"),
("3rd_age.year_5", None, "3rd_age.year_10", None, True, "open lower with at inside"),
# Era-tree membership from the OTHER side — at is a coarse era,
# the window is fine-grained. Conservatively true: the coarse
# at overlaps the era the window defines.
("3rd_age", "3rd_age.year_300", "3rd_age.year_400", None, True, "coarse at inside fine window"),
("3rd_age", "2nd_age.year_300", "2nd_age.year_400", None, False, "coarse at in a different era than window"),
# Half-open window: a_from == at is inclusive.
("3rd_age.year_300", "3rd_age.year_300", "3rd_age.year_400", None, True, "at == lo inclusive"),
("3rd_age.year_400", "3rd_age.year_300", "3rd_age.year_400", None, False, "at == hi exclusive"),
# Wrong-format strings raise.
("not a time", "3rd_age.year_1", "3rd_age.year_10", None, "?", "malformed at is rejected"),
("3rd_age.year_5", "not a lo", "3rd_age.year_10", None, "?", "malformed lo is rejected"),
("3rd_age.year_5", "3rd_age.year_1", "not a hi", None, "?", "malformed hi is rejected"),
]
@pytest.mark.parametrize(
"at, lo, hi, current, expected, desc",
ORIGINAL_CASES + ADDITIONAL_CASES,
ids=[c[5] for c in ORIGINAL_CASES + ADDITIONAL_CASES],
)
def test_time_in_window(at, lo, hi, current, expected, desc):
if expected == "?":
# Wrong-format strings must raise, not silently return.
with pytest.raises(Exception):
time_in_window(at, lo, hi, current_time=current)
return
assert time_in_window(at, lo, hi, current_time=current) is expected, desc
# ---------------------------------------------------------------------------
# Direct unit tests on the helpers — these catch regressions in the
# orthogonal primitives (normalize, is_descendant) without going through
# time_in_window.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"raw, expected",
[
(None, None),
("", None),
("null", None),
("none", None),
(" 3rd_age.year_345 ", "3rd_age.year_345"),
("CURRENT", "current"),
("3rd_age.year_345", "3rd_age.year_345"),
],
)
def test_normalize(raw, expected):
assert normalize(raw) == expected
@pytest.mark.parametrize(
"child, ancestor, expected",
[
("3rd_age.year_345", "3rd_age", True),
("3rd_age.age_of_iron", "3rd_age", True),
("3rd_age.age_of_iron.year_3", "3rd_age.age_of_iron", True),
("3rd_age.age_of_iron.year_3", "3rd_age", True),
("2nd_age.year_5", "3rd_age", False),
("3rd_age", "3rd_age", True),
(None, "3rd_age", False),
("3rd_age.year_5", None, False),
# The "vord" case: 3rd_age_vord is NOT a descendant of 3rd_age_v
# because the prefix match is "3rd_age_v" not "3rd_age."
("3rd_age_vord.year_5", "3rd_age", False),
],
)
def test_is_descendant(child, ancestor, expected):
assert is_descendant(child, ancestor) is expected
def test_current_token_requires_current_time():
"""AC 1.6: 'current' without current_time raises ValueError."""
with pytest.raises(ValueError):
time_in_window("current", "3rd_age.year_1", "3rd_age.year_10")