mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
Co-authored-by: JHC56 <246447183+JHC56@users.noreply.github.com> Co-authored-by: Byron Wang <byron@dify.ai>
398 lines
15 KiB
Python
398 lines
15 KiB
Python
import datetime
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
import pytest
|
|
|
|
from libs.datetime_utils import (
|
|
Timing,
|
|
localize_datetime,
|
|
measure_time,
|
|
naive_utc_now,
|
|
parse_time_range,
|
|
to_utc_timestamp,
|
|
utc_now,
|
|
)
|
|
|
|
|
|
def test_utc_now(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
expected = datetime.datetime(2026, 8, 26, 12, tzinfo=datetime.UTC)
|
|
|
|
def _now_func(tz: datetime.timezone | None) -> datetime.datetime:
|
|
return expected.astimezone(tz)
|
|
|
|
monkeypatch.setattr("libs.datetime_utils._now_func", _now_func)
|
|
|
|
assert utc_now() == expected
|
|
assert utc_now().tzinfo is datetime.UTC
|
|
|
|
|
|
def test_naive_utc_now(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
tz_aware_utc_now = datetime.datetime.now(tz=datetime.UTC)
|
|
|
|
def _now_func(tz: datetime.timezone | None) -> datetime.datetime:
|
|
return tz_aware_utc_now.astimezone(tz)
|
|
|
|
monkeypatch.setattr("libs.datetime_utils._now_func", _now_func)
|
|
|
|
naive_datetime = naive_utc_now()
|
|
|
|
assert naive_datetime.tzinfo is None
|
|
assert naive_datetime.date() == tz_aware_utc_now.date()
|
|
naive_time = naive_datetime.time()
|
|
utc_time = tz_aware_utc_now.time()
|
|
assert naive_time == utc_time
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
datetime.datetime(2024, 1, 1),
|
|
datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC),
|
|
datetime.datetime(2024, 1, 1, 9, tzinfo=datetime.timezone(datetime.timedelta(hours=9))),
|
|
],
|
|
)
|
|
def test_to_utc_timestamp(value: datetime.datetime) -> None:
|
|
assert to_utc_timestamp(value) == 1704067200
|
|
|
|
|
|
def test_measure_time_yields_aware_utc_timing() -> None:
|
|
with measure_time() as timer:
|
|
pass
|
|
|
|
assert timer.started_at.tzinfo is datetime.UTC
|
|
assert timer.ended_at.tzinfo is datetime.UTC
|
|
assert timer.ended_at >= timer.started_at
|
|
|
|
|
|
def test_measure_time_records_end_when_block_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
started_at = datetime.datetime(2026, 8, 26, 12, tzinfo=datetime.UTC)
|
|
ended_at = started_at + datetime.timedelta(seconds=2)
|
|
clock = iter([started_at, ended_at])
|
|
monkeypatch.setattr("libs.datetime_utils._now_func", lambda tz: next(clock).astimezone(tz))
|
|
|
|
with pytest.raises(RuntimeError, match="boom"), measure_time() as timer:
|
|
raise RuntimeError("boom")
|
|
|
|
assert timer == Timing(started_at=started_at, ended_at=ended_at)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("naive", "tzname", "expected_local", "expected_utc"),
|
|
[
|
|
# Unambiguous wall time
|
|
(
|
|
datetime.datetime(2024, 1, 1, 10, 0),
|
|
"Asia/Shanghai",
|
|
datetime.datetime(2024, 1, 1, 10, 0),
|
|
datetime.datetime(2024, 1, 1, 2, 0),
|
|
),
|
|
# Ambiguous (fall back): later occurrence, 01:30 EST
|
|
(
|
|
datetime.datetime(2024, 11, 3, 1, 30),
|
|
"America/New_York",
|
|
datetime.datetime(2024, 11, 3, 1, 30),
|
|
datetime.datetime(2024, 11, 3, 6, 30),
|
|
),
|
|
# Nonexistent (spring forward): shifted past the gap, 03:30 EDT
|
|
(
|
|
datetime.datetime(2024, 3, 10, 2, 30),
|
|
"America/New_York",
|
|
datetime.datetime(2024, 3, 10, 3, 30),
|
|
datetime.datetime(2024, 3, 10, 7, 30),
|
|
),
|
|
# Negative-DST fall back: choose the later GMT occurrence
|
|
(
|
|
datetime.datetime(2024, 10, 27, 1, 30),
|
|
"Europe/Dublin",
|
|
datetime.datetime(2024, 10, 27, 1, 30),
|
|
datetime.datetime(2024, 10, 27, 1, 30),
|
|
),
|
|
# Negative-DST spring gap: shift 01:30 forward to 02:30 IST
|
|
(
|
|
datetime.datetime(2024, 3, 31, 1, 30),
|
|
"Europe/Dublin",
|
|
datetime.datetime(2024, 3, 31, 2, 30),
|
|
datetime.datetime(2024, 3, 31, 1, 30),
|
|
),
|
|
# 30-minute spring gap: shift 02:15 forward to 02:45 LHDT
|
|
(
|
|
datetime.datetime(2024, 10, 6, 2, 15),
|
|
"Australia/Lord_Howe",
|
|
datetime.datetime(2024, 10, 6, 2, 45),
|
|
datetime.datetime(2024, 10, 5, 15, 45),
|
|
),
|
|
# Two-hour spring gap: shift 01:30 forward to 03:30 CEST
|
|
(
|
|
datetime.datetime(2024, 3, 31, 1, 30),
|
|
"Antarctica/Troll",
|
|
datetime.datetime(2024, 3, 31, 3, 30),
|
|
datetime.datetime(2024, 3, 31, 1, 30),
|
|
),
|
|
],
|
|
)
|
|
def test_localize_datetime(
|
|
naive: datetime.datetime, tzname: str, expected_local: datetime.datetime, expected_utc: datetime.datetime
|
|
) -> None:
|
|
localized = localize_datetime(naive, ZoneInfo(tzname))
|
|
|
|
assert localized.tzinfo == ZoneInfo(tzname)
|
|
assert localized.replace(tzinfo=None) == expected_local
|
|
assert localized.astimezone(datetime.UTC) == expected_utc.replace(tzinfo=datetime.UTC)
|
|
|
|
|
|
class TestParseTimeRange:
|
|
"""Test cases for parse_time_range function."""
|
|
|
|
def test_parse_time_range_basic(self) -> None:
|
|
"""Test basic time range parsing."""
|
|
start, end = parse_time_range("2024-01-01 10:00", "2024-01-01 18:00", "UTC")
|
|
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start < end
|
|
assert start.tzinfo == datetime.UTC
|
|
assert end.tzinfo == datetime.UTC
|
|
|
|
def test_parse_time_range_start_only(self) -> None:
|
|
"""Test parsing with only start time."""
|
|
start, end = parse_time_range("2024-01-01 10:00", None, "UTC")
|
|
|
|
assert start is not None
|
|
assert end is None
|
|
assert start.tzinfo == datetime.UTC
|
|
|
|
def test_parse_time_range_end_only(self) -> None:
|
|
"""Test parsing with only end time."""
|
|
start, end = parse_time_range(None, "2024-01-01 18:00", "UTC")
|
|
|
|
assert start is None
|
|
assert end is not None
|
|
assert end.tzinfo == datetime.UTC
|
|
|
|
def test_parse_time_range_both_none(self) -> None:
|
|
"""Test parsing with both times None."""
|
|
start, end = parse_time_range(None, None, "UTC")
|
|
|
|
assert start is None
|
|
assert end is None
|
|
|
|
def test_parse_time_range_different_timezones(self) -> None:
|
|
"""Test parsing with different timezones."""
|
|
# Test with US/Eastern timezone
|
|
start, end = parse_time_range("2024-01-01 10:00", "2024-01-01 18:00", "US/Eastern")
|
|
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start.tzinfo == datetime.UTC
|
|
assert end.tzinfo == datetime.UTC
|
|
# Verify the times are correctly converted to UTC
|
|
assert start.hour == 15 # 10 AM EST = 3 PM UTC (in January)
|
|
assert end.hour == 23 # 6 PM EST = 11 PM UTC (in January)
|
|
|
|
def test_parse_time_range_invalid_start_format(self) -> None:
|
|
"""Test parsing with invalid start time format."""
|
|
with pytest.raises(ValueError, match="time data.*does not match format"):
|
|
parse_time_range("invalid-date", "2024-01-01 18:00", "UTC")
|
|
|
|
def test_parse_time_range_invalid_end_format(self) -> None:
|
|
"""Test parsing with invalid end time format."""
|
|
with pytest.raises(ValueError, match="time data.*does not match format"):
|
|
parse_time_range("2024-01-01 10:00", "invalid-date", "UTC")
|
|
|
|
def test_parse_time_range_invalid_timezone(self) -> None:
|
|
"""Test parsing with invalid timezone."""
|
|
with pytest.raises(ZoneInfoNotFoundError):
|
|
parse_time_range("2024-01-01 10:00", "2024-01-01 18:00", "Invalid/Timezone")
|
|
|
|
def test_parse_time_range_start_after_end(self) -> None:
|
|
"""Test parsing with start time after end time."""
|
|
with pytest.raises(ValueError, match="start must be earlier than or equal to end"):
|
|
parse_time_range("2024-01-01 18:00", "2024-01-01 10:00", "UTC")
|
|
|
|
def test_parse_time_range_start_equals_end(self) -> None:
|
|
"""Test parsing with start time equal to end time."""
|
|
start, end = parse_time_range("2024-01-01 10:00", "2024-01-01 10:00", "UTC")
|
|
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start == end
|
|
|
|
@pytest.mark.parametrize(
|
|
("tzname", "time_str", "expected"),
|
|
[
|
|
pytest.param(
|
|
"America/New_York",
|
|
"2024-11-03 01:30",
|
|
datetime.datetime(2024, 11, 3, 6, 30, tzinfo=datetime.UTC),
|
|
id="positive-dst",
|
|
),
|
|
pytest.param(
|
|
"Europe/Dublin",
|
|
"2024-10-27 01:30",
|
|
datetime.datetime(2024, 10, 27, 1, 30, tzinfo=datetime.UTC),
|
|
id="negative-dst",
|
|
),
|
|
],
|
|
)
|
|
def test_parse_time_range_ambiguous_local_time_uses_later_occurrence(
|
|
self, tzname: str, time_str: str, expected: datetime.datetime
|
|
) -> None:
|
|
start, end = parse_time_range(time_str, time_str, tzname)
|
|
|
|
assert start == end == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
("tzname", "time_str", "expected"),
|
|
[
|
|
pytest.param(
|
|
"America/New_York",
|
|
"2024-03-10 02:30",
|
|
datetime.datetime(2024, 3, 10, 7, 30, tzinfo=datetime.UTC),
|
|
id="one-hour-gap",
|
|
),
|
|
pytest.param(
|
|
"Australia/Lord_Howe",
|
|
"2024-10-06 02:15",
|
|
datetime.datetime(2024, 10, 5, 15, 45, tzinfo=datetime.UTC),
|
|
id="half-hour-gap",
|
|
),
|
|
pytest.param(
|
|
"Antarctica/Troll",
|
|
"2024-03-31 01:30",
|
|
datetime.datetime(2024, 3, 31, 1, 30, tzinfo=datetime.UTC),
|
|
id="two-hour-gap",
|
|
),
|
|
pytest.param(
|
|
"Pacific/Apia",
|
|
"2011-12-30 12:00",
|
|
datetime.datetime(2011, 12, 30, 22, tzinfo=datetime.UTC),
|
|
id="skipped-day",
|
|
),
|
|
pytest.param(
|
|
"Europe/Dublin",
|
|
"2024-03-31 01:30",
|
|
datetime.datetime(2024, 3, 31, 1, 30, tzinfo=datetime.UTC),
|
|
id="negative-dst",
|
|
),
|
|
pytest.param(
|
|
"Asia/Kathmandu",
|
|
"1986-01-01 00:05",
|
|
datetime.datetime(1985, 12, 31, 18, 35, tzinfo=datetime.UTC),
|
|
id="non-dst-quarter-hour-gap",
|
|
),
|
|
],
|
|
)
|
|
def test_parse_time_range_nonexistent_local_time(
|
|
self, tzname: str, time_str: str, expected: datetime.datetime
|
|
) -> None:
|
|
"""Both bounds shift forward by the real gap size and resolve to exact UTC instants."""
|
|
start, end = parse_time_range(time_str, time_str, tzname)
|
|
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start == end == expected
|
|
assert start.tzinfo == end.tzinfo == datetime.UTC
|
|
|
|
def test_parse_time_range_edge_cases(self) -> None:
|
|
"""Test edge cases for time parsing."""
|
|
# Test with midnight times
|
|
start, end = parse_time_range("2024-01-01 00:00", "2024-01-01 23:59", "UTC")
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start.hour == 0
|
|
assert start.minute == 0
|
|
assert end.hour == 23
|
|
assert end.minute == 59
|
|
|
|
def test_parse_time_range_different_dates(self) -> None:
|
|
"""Test parsing with different dates."""
|
|
start, end = parse_time_range("2024-01-01 10:00", "2024-01-02 10:00", "UTC")
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start.date() != end.date()
|
|
assert (end - start).days == 1
|
|
|
|
def test_parse_time_range_seconds_handling(self) -> None:
|
|
"""Test that seconds are properly set to 0."""
|
|
start, end = parse_time_range("2024-01-01 10:30", "2024-01-01 18:45", "UTC")
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start.second == 0
|
|
assert end.second == 0
|
|
|
|
def test_parse_time_range_timezone_conversion_accuracy(self) -> None:
|
|
"""Test accurate timezone conversion."""
|
|
# Test with a known timezone conversion
|
|
start, end = parse_time_range("2024-01-01 12:00", "2024-01-01 12:00", "Asia/Tokyo")
|
|
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start.tzinfo == datetime.UTC
|
|
assert end.tzinfo == datetime.UTC
|
|
# Tokyo is UTC+9, so 12:00 JST = 03:00 UTC
|
|
assert start.hour == 3
|
|
assert end.hour == 3
|
|
|
|
def test_parse_time_range_summer_time(self) -> None:
|
|
"""Test parsing during summer time (DST)."""
|
|
# Test with US/Eastern during summer (EDT = UTC-4)
|
|
start, end = parse_time_range("2024-07-01 12:00", "2024-07-01 12:00", "US/Eastern")
|
|
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start.tzinfo == datetime.UTC
|
|
assert end.tzinfo == datetime.UTC
|
|
# 12:00 EDT = 16:00 UTC
|
|
assert start.hour == 16
|
|
assert end.hour == 16
|
|
|
|
def test_parse_time_range_winter_time(self) -> None:
|
|
"""Test parsing during winter time (standard time)."""
|
|
# Test with US/Eastern during winter (EST = UTC-5)
|
|
start, end = parse_time_range("2024-01-01 12:00", "2024-01-01 12:00", "US/Eastern")
|
|
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start.tzinfo == datetime.UTC
|
|
assert end.tzinfo == datetime.UTC
|
|
# 12:00 EST = 17:00 UTC
|
|
assert start.hour == 17
|
|
assert end.hour == 17
|
|
|
|
def test_parse_time_range_empty_strings(self) -> None:
|
|
"""Test parsing with empty strings."""
|
|
# Empty strings are treated as None, so they should not raise errors
|
|
start, end = parse_time_range("", "2024-01-01 18:00", "UTC")
|
|
assert start is None
|
|
assert end is not None
|
|
|
|
start, end = parse_time_range("2024-01-01 10:00", "", "UTC")
|
|
assert start is not None
|
|
assert end is None
|
|
|
|
def test_parse_time_range_malformed_datetime(self) -> None:
|
|
"""Test parsing with malformed datetime strings."""
|
|
with pytest.raises(ValueError, match="time data.*does not match format"):
|
|
parse_time_range("2024-13-01 10:00", "2024-01-01 18:00", "UTC")
|
|
|
|
with pytest.raises(ValueError, match="time data.*does not match format"):
|
|
parse_time_range("2024-01-01 10:00", "2024-01-32 18:00", "UTC")
|
|
|
|
def test_parse_time_range_very_long_time_range(self) -> None:
|
|
"""Test parsing with very long time range."""
|
|
start, end = parse_time_range("2020-01-01 00:00", "2030-12-31 23:59", "UTC")
|
|
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start < end
|
|
assert (end - start).days > 3000 # More than 8 years
|
|
|
|
def test_parse_time_range_negative_timezone(self) -> None:
|
|
"""Test parsing with negative timezone offset."""
|
|
start, end = parse_time_range("2024-01-01 12:00", "2024-01-01 12:00", "America/New_York")
|
|
|
|
assert start is not None
|
|
assert end is not None
|
|
assert start.tzinfo == datetime.UTC
|
|
assert end.tzinfo == datetime.UTC
|