|
| 1 | +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | +"""ATIF step timestamps must be real UTC, not local time wearing a Z suffix. |
| 4 | +
|
| 5 | +``_iso8601_utc`` used to format whatever tzinfo it was handed and append ``Z`` |
| 6 | +unconditionally. Event timestamps arrive naive (local), while the no-argument |
| 7 | +default is already UTC-aware, so a single trajectory ended up mixing both and |
| 8 | +its steps were not in chronological order. |
| 9 | +""" |
| 10 | + |
| 11 | +import os |
| 12 | +import time |
| 13 | +from datetime import UTC, datetime, timedelta, timezone |
| 14 | + |
| 15 | +import pytest |
| 16 | + |
| 17 | +from nooa.atif.exporter import _iso8601_utc |
| 18 | + |
| 19 | + |
| 20 | +def _hour(stamp: str) -> int: |
| 21 | + return int(stamp[11:13]) |
| 22 | + |
| 23 | + |
| 24 | +class TestIso8601Utc: |
| 25 | + def test_aware_utc_is_unchanged(self): |
| 26 | + dt = datetime(2026, 7, 30, 10, 6, 42, 680_000, tzinfo=UTC) |
| 27 | + assert _iso8601_utc(dt) == "2026-07-30T10:06:42.680Z" |
| 28 | + |
| 29 | + def test_aware_non_utc_is_converted(self): |
| 30 | + """A +02:00 wall clock of 12:06 is 10:06 UTC.""" |
| 31 | + dt = datetime(2026, 7, 30, 12, 6, 42, 680_000, tzinfo=timezone(timedelta(hours=2))) |
| 32 | + assert _iso8601_utc(dt) == "2026-07-30T10:06:42.680Z" |
| 33 | + |
| 34 | + def test_negative_offset_is_converted(self): |
| 35 | + dt = datetime(2026, 7, 30, 6, 6, 42, 680_000, tzinfo=timezone(timedelta(hours=-4))) |
| 36 | + assert _iso8601_utc(dt) == "2026-07-30T10:06:42.680Z" |
| 37 | + |
| 38 | + def test_naive_is_interpreted_as_local(self): |
| 39 | + """Naive event timestamps are local time and must be shifted to UTC.""" |
| 40 | + naive = datetime.now() |
| 41 | + assert _iso8601_utc(naive) == _iso8601_utc(naive.astimezone(UTC)) |
| 42 | + |
| 43 | + def test_default_matches_explicit_now(self): |
| 44 | + assert abs( |
| 45 | + datetime.strptime(_iso8601_utc(), "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=UTC) |
| 46 | + - datetime.now(UTC) |
| 47 | + ) < timedelta(seconds=5) |
| 48 | + |
| 49 | + @pytest.mark.skipif(not hasattr(time, "tzset"), reason="tzset() is POSIX-only") |
| 50 | + def test_naive_and_default_agree_under_a_non_utc_tz(self): |
| 51 | + """The regression: two code paths must not disagree by the UTC offset. |
| 52 | +
|
| 53 | + Under a non-UTC local zone, an event-sourced (naive) timestamp and the |
| 54 | + no-argument default previously landed hours apart in the same file. |
| 55 | + """ |
| 56 | + prev = os.environ.get("TZ") |
| 57 | + try: |
| 58 | + os.environ["TZ"] = "Europe/Berlin" # UTC+2 in July |
| 59 | + time.tzset() |
| 60 | + from_event = _iso8601_utc(datetime.now()) # naive → local |
| 61 | + from_default = _iso8601_utc() # aware → UTC |
| 62 | + assert _hour(from_event) == _hour(from_default) |
| 63 | + finally: |
| 64 | + if prev is None: |
| 65 | + os.environ.pop("TZ", None) |
| 66 | + else: |
| 67 | + os.environ["TZ"] = prev |
| 68 | + time.tzset() |
0 commit comments