/* * test_time.cpp — TIMESTAMP §6.2.2 + DURATION §6.2.3 * (ANTHEOS-TIMESTAMP-V1-PARSER-IMPL, ANTHEOS-DURATION-V1-PARSER-IMPL) * * Both spec tables, row for row: every accepted input form and every rejected * one. The property the whole discipline exists for is that two conformant * implementations produce BYTE-IDENTICAL words for the same value, so nearly * every assertion here compares against an exact string rather than checking * that something parsed. * * Copyright (c) 2025-2026 Are Bjørby * SPDX-License-Identifier: MIT */ #include "test_common.hpp" #include "antheos.hpp" #include using namespace antheos; using namespace antheos::iso; namespace { /* Parse then emit — the canonicalisation, as one step. */ std::string ts_canon(std::string_view body) { auto t = parse_timestamp(body); return t ? emit_timestamp(*t) : std::string(""); } std::string du_canon(std::string_view body) { auto d = parse_duration(body); return d ? emit_duration(*d) : std::string(""); } bool ts_ok(std::string_view b) { return parse_timestamp(b).has_value(); } bool du_ok(std::string_view b) { return parse_duration(b).has_value(); } /* ── TIMESTAMP: accepted input forms (§6.2.2) ── */ void test_ts_canonical_form_is_27_bytes() { const std::string c = ts_canon("2026-02-08T12:00:00.000000Z"); ASSERT_TRUE(c == "2026-02-08T12:00:00.000000Z"); ASSERT_EQ(c.size(), 27u); /* the spec says exactly 27 */ } void test_ts_pads_absent_and_short_fractions() { ASSERT_TRUE(ts_canon("2026-02-08T12:00:00") == "2026-02-08T12:00:00.000000Z"); ASSERT_TRUE(ts_canon("2026-02-08T12:00:00Z") == "2026-02-08T12:00:00.000000Z"); ASSERT_TRUE(ts_canon("2026-02-08T12:00:00.5Z") == "2026-02-08T12:00:00.500000Z"); ASSERT_TRUE(ts_canon("2026-02-08T12:00:00.123Z") == "2026-02-08T12:00:00.123000Z"); } void test_ts_truncates_sub_microsecond_never_rounds() { /* The spec is explicit that this is truncation: rounding admits multiple * valid implementations, truncation has one. `.1234569` would round UP in * the last kept digit — it must not. */ ASSERT_TRUE(ts_canon("2026-02-08T12:00:00.123456789Z") == "2026-02-08T12:00:00.123456Z"); ASSERT_TRUE(ts_canon("2026-02-08T12:00:00.1234569Z") == "2026-02-08T12:00:00.123456Z"); ASSERT_TRUE(ts_canon("2026-02-08T12:00:00.9999999Z") == "2026-02-08T12:00:00.999999Z"); } void test_ts_converts_offset_to_utc() { /* A positive offset is ahead of UTC, so UTC is EARLIER. */ ASSERT_TRUE(ts_canon("2026-02-08T12:00:00+02:00") == "2026-02-08T10:00:00.000000Z"); ASSERT_TRUE(ts_canon("2026-02-08T12:00:00-05:30") == "2026-02-08T17:30:00.000000Z"); /* Crossing midnight backwards, and a month boundary with it. */ ASSERT_TRUE(ts_canon("2026-03-01T00:30:00+02:00") == "2026-02-28T22:30:00.000000Z"); /* Crossing a leap day forwards — 2024 is a leap year. */ ASSERT_TRUE(ts_canon("2024-02-28T23:30:00-01:00") == "2024-02-29T00:30:00.000000Z"); } void test_ts_accepts_leap_day_and_rejects_a_false_one() { ASSERT_TRUE(ts_ok("2024-02-29T00:00:00Z")); /* 2024 is a leap year */ ASSERT_TRUE(!ts_ok("2026-02-29T00:00:00Z")); /* 2026 is not */ ASSERT_TRUE(!ts_ok("2100-02-29T00:00:00Z")); /* century, not a leap year */ ASSERT_TRUE(ts_ok("2000-02-29T00:00:00Z")); /* 400-year rule */ } /* ── TIMESTAMP: rejected input forms (§6.2.2) ── */ void test_ts_rejects_named_zone_with_teaching() { std::string why; ASSERT_TRUE(!parse_timestamp("2026-02-08T12:00:00America/New_York", &why)); ASSERT_TRUE(!why.empty()); ASSERT_TRUE(!ts_ok("2026-02-08T12:00:00UTC")); } void test_ts_rejects_extended_year() { std::string why; ASSERT_TRUE(!parse_timestamp("+002026-02-08T12:00:00Z", &why)); ASSERT_TRUE(why.find("xtended") != std::string::npos); ASSERT_TRUE(!ts_ok("-002026-02-08T12:00:00Z")); } void test_ts_rejects_leap_second() { std::string why; ASSERT_TRUE(!parse_timestamp("2026-12-31T23:59:60Z", &why)); ASSERT_TRUE(why.find("eap second") != std::string::npos); } void test_ts_rejects_empty_and_malformed() { std::string why; ASSERT_TRUE(!parse_timestamp("", &why)); ASSERT_TRUE(!why.empty()); ASSERT_TRUE(!ts_ok("2026-2-8T12:00:00Z")); /* unpadded fields */ ASSERT_TRUE(!ts_ok("2026-02-08 12:00:00Z")); /* space for the T */ ASSERT_TRUE(!ts_ok("2026-13-08T12:00:00Z")); /* month out of range */ ASSERT_TRUE(!ts_ok("2026-02-08T24:00:00Z")); /* hour out of range */ ASSERT_TRUE(!ts_ok("2026-02-08T12:00:00.Z")); /* point, no digits */ ASSERT_TRUE(!ts_ok("2026-02-08T12:00:00Zjunk")); } /* ── DURATION: accepted input forms (§6.2.3) ── */ void test_du_full_and_partial_forms() { ASSERT_TRUE(du_canon("P3Y6M4DT12H30M5S") == "P3Y6M4DT12H30M5S"); ASSERT_TRUE(du_canon("PT1H30M") == "PT1H30M"); ASSERT_TRUE(du_canon("P2W") == "P2W"); ASSERT_TRUE(du_canon("-P3D") == "-P3D"); ASSERT_TRUE(du_canon("PT0.123456S") == "PT0.123456S"); } void test_du_zero_forms_all_canonicalise_to_PT0S() { ASSERT_TRUE(du_canon("P0D") == "PT0S"); ASSERT_TRUE(du_canon("P0Y") == "PT0S"); ASSERT_TRUE(du_canon("PT0S") == "PT0S"); ASSERT_TRUE(du_canon("P0W") == "PT0S"); /* A negative zero is still just zero — one spelling for nothing. */ ASSERT_TRUE(du_canon("-P0D") == "PT0S"); } void test_du_drops_zero_components() { ASSERT_TRUE(du_canon("P3Y0M4D") == "P3Y4D"); ASSERT_TRUE(du_canon("PT0H30M0S") == "PT30M"); ASSERT_TRUE(du_canon("P0Y0M0DT5S") == "PT5S"); } void test_du_seconds_fraction_rules() { /* Integer seconds carry no decimal point at all. */ ASSERT_TRUE(du_canon("PT5S") == "PT5S"); /* A short fraction pads to six. */ ASSERT_TRUE(du_canon("PT0.123S") == "PT0.123000S"); /* A long one truncates, never rounds. */ ASSERT_TRUE(du_canon("PT0.123456789S") == "PT0.123456S"); ASSERT_TRUE(du_canon("PT0.1234569S") == "PT0.123456S"); } void test_du_weeks_form_is_preserved_not_converted() { /* §6.2.3 is explicit: the canonical form keeps the input's component shape, * so P2W must NOT become P14D. */ ASSERT_TRUE(du_canon("P2W") == "P2W"); ASSERT_TRUE(du_canon("-P2W") == "-P2W"); ASSERT_TRUE(du_canon("P2W") != "P14D"); } /* ── DURATION: rejected input forms (§6.2.3) ── */ void test_du_rejects_empty_and_bare_P() { std::string why; ASSERT_TRUE(!parse_duration("", &why)); ASSERT_TRUE(!why.empty()); ASSERT_TRUE(!du_ok("P")); ASSERT_TRUE(!du_ok("-P")); } void test_du_rejects_mixed_weeks_and_calendar() { std::string why; ASSERT_TRUE(!parse_duration("P2W3D", &why)); ASSERT_TRUE(why.find("mutually exclusive") != std::string::npos); ASSERT_TRUE(!du_ok("P3D2W")); ASSERT_TRUE(!du_ok("P2WT1H")); /* and the weeks form carries no time part */ } void test_du_rejects_missing_P_and_missing_T() { ASSERT_TRUE(!du_ok("3Y6M")); /* the P prefix is mandatory */ ASSERT_TRUE(!du_ok("P12H30M")); /* time components need their T */ ASSERT_TRUE(!du_ok("PT")); /* T with nothing after it */ ASSERT_TRUE(!du_ok("P3")); /* a number with no component letter */ ASSERT_TRUE(!du_ok("PY")); /* a component letter with no number */ ASSERT_TRUE(!du_ok("PT1.5H")); /* only seconds may carry a fraction */ } /* ── The property both types exist for ── */ void test_canonical_forms_are_idempotent() { /* Emitting a canonical body and parsing it again must land on the same * bytes: that is what "two conformant implementations agree" reduces to * within one implementation, and it is cheap to check. */ const char* stamps[] = { "2026-02-08T12:00:00", "2026-02-08T12:00:00.5Z", "2026-02-08T12:00:00+02:00", "2024-02-29T23:59:59.999999Z", }; for (const char* s : stamps) { const std::string once = ts_canon(s); ASSERT_TRUE(once != ""); ASSERT_TRUE(ts_canon(once) == once); } const char* durs[] = { "P3Y6M4DT12H30M5S", "PT1H30M", "P2W", "-P3D", "PT0.123S", "P0D", }; for (const char* s : durs) { const std::string once = du_canon(s); ASSERT_TRUE(once != ""); ASSERT_TRUE(du_canon(once) == once); } } } // namespace void test_time_run(int& out_run, int& out_passed) { std::printf("\n[time]\n"); TEST(test_ts_canonical_form_is_27_bytes); TEST(test_ts_pads_absent_and_short_fractions); TEST(test_ts_truncates_sub_microsecond_never_rounds); TEST(test_ts_converts_offset_to_utc); TEST(test_ts_accepts_leap_day_and_rejects_a_false_one); TEST(test_ts_rejects_named_zone_with_teaching); TEST(test_ts_rejects_extended_year); TEST(test_ts_rejects_leap_second); TEST(test_ts_rejects_empty_and_malformed); TEST(test_du_full_and_partial_forms); TEST(test_du_zero_forms_all_canonicalise_to_PT0S); TEST(test_du_drops_zero_components); TEST(test_du_seconds_fraction_rules); TEST(test_du_weeks_form_is_preserved_not_converted); TEST(test_du_rejects_empty_and_bare_P); TEST(test_du_rejects_mixed_weeks_and_calendar); TEST(test_du_rejects_missing_P_and_missing_T); TEST(test_canonical_forms_are_idempotent); out_run = tests_run; out_passed = tests_passed; }