mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-21 17:28:11 +00:00
This fix was taken from https://github.com/arneman/EOS/tree/refactor/economic-objective. Avoid flaky TC026 by comparing timestamps at assertion time. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
1578 lines
52 KiB
Python
1578 lines
52 KiB
Python
"""Comprehensive test suite for the date/time utility module.
|
||
|
||
This test suite covers all classes and functions in the datetimeutil module,
|
||
including edge cases, error handling, and timezone behavior.
|
||
"""
|
||
|
||
import datetime
|
||
import json
|
||
import re
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import babel
|
||
import pendulum
|
||
import pytest
|
||
from pendulum.tz.timezone import Timezone
|
||
from pydantic import ValidationError
|
||
|
||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||
from akkudoktoreos.utils.datetimeutil import (
|
||
MAX_DURATION_STRING_LENGTH,
|
||
Date,
|
||
DateTime,
|
||
Duration,
|
||
Time,
|
||
_parse_time_string,
|
||
compare_datetimes,
|
||
hours_in_day,
|
||
to_datetime,
|
||
to_duration,
|
||
to_time,
|
||
to_timezone,
|
||
)
|
||
|
||
# ----------
|
||
# Time Class
|
||
# ----------
|
||
|
||
|
||
class TestTimeParsing:
|
||
"""Comprehensive tests for string → pendulum.Time conversion and roundtrip correctness."""
|
||
|
||
# -------------------------------
|
||
# VALID FORMATS
|
||
# -------------------------------
|
||
@pytest.mark.parametrize(
|
||
"input_str, expected",
|
||
[
|
||
# 24-hour basic formats
|
||
("00:00", (0, 0, 0)),
|
||
("23:59:59", (23, 59, 59)),
|
||
("14:30", (14, 30, 0)),
|
||
("14:30:45", (14, 30, 45)),
|
||
("14:30:45.123456", (14, 30, 45)),
|
||
("1430", (14, 30, 0)),
|
||
("143045", (14, 30, 45)),
|
||
("930", (9, 30, 0)),
|
||
("14", (14, 0, 0)),
|
||
("14.5", (14, 30, 0)),
|
||
("14.25", (14, 15, 0)),
|
||
("14h30", (14, 30, 0)),
|
||
("14-30", (14, 30, 0)),
|
||
("14 30", (14, 30, 0)),
|
||
|
||
# 12-hour AM/PM formats
|
||
("12:00 AM", (0, 0, 0)),
|
||
("12:00 PM", (12, 0, 0)),
|
||
("2:30 PM", (14, 30, 0)),
|
||
("2:30:45 PM", (14, 30, 45)),
|
||
("2PM", (14, 0, 0)),
|
||
("11AM", (11, 0, 0)),
|
||
|
||
# Compact & decimal
|
||
("3", (3, 0, 0)),
|
||
("23.75", (23, 45, 0)),
|
||
|
||
# Offset-based / ISO-like
|
||
("08:00:00.000000+01:00", (8, 0, 0)),
|
||
("14:30 +05:30", (14, 30, 0)),
|
||
("14:30 -03:00", (14, 30, 0)),
|
||
("22:15 -0800", (22, 15, 0)),
|
||
|
||
# Alternative separators
|
||
("14-30", (14, 30, 0)),
|
||
("14 30", (14, 30, 0)),
|
||
("14-30-45", (14, 30, 45)),
|
||
("14 30 45", (14, 30, 45)),
|
||
|
||
# Timezones by abbreviation
|
||
("14:30 UTC", (14, 30, 0)),
|
||
("14:30 GMT", (14, 30, 0)),
|
||
("2:30 PM EST", (14, 30, 0)),
|
||
("9:15 CST", (9, 15, 0)),
|
||
("23:59 PST", (23, 59, 0)),
|
||
|
||
# Named timezones
|
||
("14h30 Europe/Berlin", (14, 30, 0)),
|
||
("14:30 America/New_York", (14, 30, 0)),
|
||
("08:15 Asia/Tokyo", (8, 15, 0)),
|
||
("23:45 Australia/Sydney", (23, 45, 0)),
|
||
],
|
||
)
|
||
def test_parse_time_string_valid(self, input_str, expected):
|
||
"""Ensure various valid time strings parse correctly."""
|
||
result = _parse_time_string(input_str)
|
||
assert isinstance(result, pendulum.Time)
|
||
assert (result.hour, result.minute, result.second) == expected[:3]
|
||
|
||
# -------------------------------
|
||
# INVALID INPUTS
|
||
# -------------------------------
|
||
@pytest.mark.parametrize(
|
||
"input_str",
|
||
[
|
||
"", # empty
|
||
"25:00", # invalid hour
|
||
"14:61", # invalid minute
|
||
"2:30 XM", # bad AM/PM
|
||
"noonish", # nonsense
|
||
"2400", # invalid compact
|
||
"24.999", # beyond 23.999
|
||
"14:30 Mars/Terra", # invalid tz
|
||
],
|
||
)
|
||
def test_parse_time_string_invalid(self, input_str):
|
||
"""Invalid inputs should raise ValueError."""
|
||
with pytest.raises(ValueError):
|
||
_parse_time_string(input_str)
|
||
|
||
# -------------------------------
|
||
# TIMEZONE HANDLING
|
||
# -------------------------------
|
||
@pytest.mark.parametrize(
|
||
"input_str, tz_name",
|
||
[
|
||
("14:30 UTC", "UTC"),
|
||
("14:30 Europe/Berlin", "Europe/Berlin"),
|
||
("2:30 PM PST", "America/Los_Angeles"),
|
||
("08:00:00.000000+01:00", "+01:00"),
|
||
("14:30 +05:30", "+05:30"),
|
||
("22:00 -04:00", "-04:00"),
|
||
],
|
||
)
|
||
def test_parse_time_string_with_timezone(self, input_str, tz_name):
|
||
"""Test timezone-aware parsing results in a Time with tzinfo."""
|
||
t = _parse_time_string(input_str)
|
||
assert isinstance(t, pendulum.Time)
|
||
assert t.tzinfo is not None
|
||
# compare normalized zone name
|
||
assert tz_name.split("/")[-1] in str(t.tzinfo) or tz_name in str(t.tzinfo), f"{str(t.tzinfo)} vs. expected {tz_name}"
|
||
|
||
@pytest.mark.parametrize(
|
||
"time_str",
|
||
[
|
||
"08:00:00.000000+01:00",
|
||
"14:30 UTC",
|
||
"2:30 PM PST",
|
||
"14h30 Europe/Berlin",
|
||
"23:45 America/New_York",
|
||
],
|
||
)
|
||
def test_roundtrip_to_string(self, time_str):
|
||
"""Test that parsing and serializing preserves hour, minute, offset."""
|
||
t = _parse_time_string(time_str)
|
||
s = t.isoformat()
|
||
reparsed = _parse_time_string(s)
|
||
assert t.hour == reparsed.hour
|
||
assert t.minute == reparsed.minute
|
||
assert t.second == reparsed.second
|
||
assert t.utcoffset() == reparsed.utcoffset()
|
||
|
||
def test_microsecond_precision_and_offset(self):
|
||
"""Ensure microseconds and offset are exact."""
|
||
t = _parse_time_string("08:00:00.000001+01:00")
|
||
assert t.microsecond == 1
|
||
assert t.strftime("%z") in ("+0100", "+01:00")
|
||
|
||
def test_parse_edge_cases(self):
|
||
"""Test parsing edge cases."""
|
||
# Test with whitespace
|
||
result = _parse_time_string(" 14:30 ")
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
|
||
# Test case insensitivity
|
||
result = _parse_time_string("2:30 pm")
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
|
||
# Test mixed case
|
||
result = _parse_time_string("14H30")
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
|
||
|
||
class TestTime:
|
||
"""Test suite for the custom Time class."""
|
||
|
||
def test_time_creation_basic(self):
|
||
"""Test basic Time object creation."""
|
||
t = Time(14, 30, 45, 123456)
|
||
assert t.hour == 14
|
||
assert t.minute == 30
|
||
assert t.second == 45
|
||
assert t.microsecond == 123456
|
||
assert t.tzinfo is None
|
||
|
||
def test_time_creation_with_timezone(self):
|
||
"""Test Time object creation with timezone."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
t = Time(14, 30, 0, tzinfo=berlin_tz)
|
||
assert t.hour == 14
|
||
assert t.minute == 30
|
||
assert t.second == 0
|
||
assert t.tzinfo == berlin_tz
|
||
|
||
def test_pydantic_validation_valid_time(self):
|
||
"""Test pydantic validation with valid Time object."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
pend_time = pendulum.time(14, 30, 0).replace(tzinfo=berlin_tz)
|
||
|
||
# This should not raise an exception
|
||
validated = Time._validate(pend_time)
|
||
assert isinstance(validated, Time)
|
||
assert validated.hour == 14
|
||
assert validated.minute == 30
|
||
assert validated.tzinfo == berlin_tz
|
||
|
||
def test_pydantic_validation_valid_time_in_other_timezone(self, set_other_timezone):
|
||
"""Test pydantic validation with valid Time object running in different timezone."""
|
||
timezone = set_other_timezone()
|
||
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
pend_time = pendulum.time(14, 30, 0).replace(tzinfo=berlin_tz)
|
||
assert isinstance(pend_time.tzinfo, Timezone)
|
||
assert pend_time.tzinfo == berlin_tz
|
||
|
||
# This should not raise an exception
|
||
validated = Time._validate(pend_time)
|
||
assert isinstance(validated, Time)
|
||
assert validated.hour == 14
|
||
assert validated.minute == 30
|
||
assert validated.tzinfo == berlin_tz
|
||
|
||
def test_pydantic_validation_string_input(self):
|
||
"""Test pydantic validation with string input."""
|
||
time_str = "14:30:45"
|
||
validated = Time._validate(time_str)
|
||
assert isinstance(validated, Time)
|
||
assert validated.hour == 14
|
||
assert validated.minute == 30
|
||
assert validated.second == 45
|
||
|
||
def test_pydantic_validation_none_input(self):
|
||
"""Test pydantic validation with None input raises ValueError."""
|
||
with pytest.raises(ValueError, match="Time value cannot be None"):
|
||
Time._validate(None)
|
||
|
||
def test_pydantic_validation_invalid_input(self):
|
||
"""Test pydantic validation with invalid input."""
|
||
with pytest.raises(ValueError, match="Invalid time value"):
|
||
Time._validate("invalid_time")
|
||
|
||
def test_serialization_naive_time(self):
|
||
"""Test serialization of naive Time object."""
|
||
t = Time(14, 30, 45, 123456)
|
||
serialized = Time._serialize(t)
|
||
assert serialized == "14:30:45.123456"
|
||
|
||
def test_serialization_timezone_aware_time(self):
|
||
"""Test serialization of timezone-aware Time object."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
t = Time(14, 30, 45, 123456, tzinfo=berlin_tz)
|
||
serialized = Time._serialize(t)
|
||
assert "14:30:45.123456" in serialized
|
||
assert "Europe/Berlin" in serialized or "+0" in serialized
|
||
|
||
def test_serialization_none_value(self):
|
||
"""Test serialization of None value."""
|
||
serialized = Time._serialize(None)
|
||
assert serialized == ""
|
||
|
||
def test_repr_naive_time(self):
|
||
"""Test __repr__ for naive Time."""
|
||
t = Time(14, 30, 45, 123456)
|
||
repr_str = repr(t)
|
||
assert "Time(14, 30, 45, 123456)" in repr_str
|
||
assert "tzinfo" not in repr_str
|
||
|
||
def test_repr_timezone_aware_time(self):
|
||
"""Test __repr__ for timezone-aware Time."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
t = Time(14, 30, 45, 123456, tzinfo=berlin_tz)
|
||
repr_str = repr(t)
|
||
assert "Time(14, 30, 45, 123456, tzinfo=" in repr_str
|
||
assert "Europe/Berlin" in repr_str
|
||
|
||
def test_str_representation(self):
|
||
"""Test __str__ method."""
|
||
t = Time(14, 30, 45, 123456)
|
||
str_repr = str(t)
|
||
assert str_repr == "14:30:45.123456"
|
||
|
||
def test_equality_naive_times(self):
|
||
"""Test equality comparison for naive times."""
|
||
t1 = Time(14, 30, 45)
|
||
t2 = Time(14, 30, 45)
|
||
t3 = Time(14, 30, 46)
|
||
|
||
assert t1 == t2
|
||
assert t1 != t3
|
||
|
||
def test_equality_timezone_aware_times(self):
|
||
"""Test equality comparison for timezone-aware times."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
utc_tz = pendulum.timezone("UTC")
|
||
|
||
t1 = Time(14, 30, 0, tzinfo=berlin_tz)
|
||
t2 = Time(14, 30, 0, tzinfo=berlin_tz)
|
||
t3 = Time(14, 30, 0, tzinfo=utc_tz)
|
||
|
||
assert t1 == t2
|
||
|
||
def test_equality_mixed_timezone_naive(self):
|
||
"""Test equality comparison between timezone-aware and naive times."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
t1 = Time(14, 30, 0, tzinfo=berlin_tz)
|
||
t2 = Time(14, 30, 0) # naive
|
||
|
||
# Mixed comparison should use direct comparison
|
||
assert t1 == t2
|
||
|
||
def test_hash_naive_time(self):
|
||
"""Test hash function for naive time."""
|
||
t1 = Time(14, 30, 45)
|
||
t2 = Time(14, 30, 45)
|
||
|
||
assert hash(t1) == hash(t2)
|
||
|
||
# Test that times can be used in sets
|
||
time_set = {t1, t2}
|
||
assert len(time_set) == 1
|
||
|
||
def test_hash_timezone_aware_time(self):
|
||
"""Test hash function for timezone-aware time."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
t1 = Time(14, 30, 0, tzinfo=berlin_tz)
|
||
t2 = Time(14, 30, 0, tzinfo=berlin_tz)
|
||
|
||
assert hash(t1) == hash(t2)
|
||
|
||
def test_is_naive(self):
|
||
"""Test is_naive method."""
|
||
t_naive = Time(14, 30, 0)
|
||
t_aware = Time(14, 30, 0, tzinfo=pendulum.timezone("UTC"))
|
||
|
||
assert t_naive.is_naive() is True
|
||
assert t_aware.is_naive() is False
|
||
|
||
def test_is_aware(self):
|
||
"""Test is_aware method."""
|
||
t_naive = Time(14, 30, 0)
|
||
t_aware = Time(14, 30, 0, tzinfo=pendulum.timezone("UTC"))
|
||
|
||
assert t_naive.is_aware() is False
|
||
assert t_aware.is_aware() is True
|
||
|
||
def test_replace_timezone(self):
|
||
"""Test replace_timezone method."""
|
||
t = Time(14, 30, 0)
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
|
||
t_with_tz = t.replace_timezone(berlin_tz)
|
||
assert t_with_tz.tzinfo == berlin_tz
|
||
assert t_with_tz.hour == 14 # Time should not change
|
||
|
||
# Test with string timezone
|
||
t_with_str_tz = t.replace_timezone("UTC")
|
||
assert t_with_str_tz.tzinfo == pendulum.timezone("UTC")
|
||
|
||
def test_replace_timezone_none(self):
|
||
"""Test replace_timezone with None removes timezone."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
t = Time(14, 30, 0, tzinfo=berlin_tz)
|
||
|
||
t_naive = t.replace_timezone(None)
|
||
assert t_naive.tzinfo is None
|
||
|
||
def test_format_user_friendly_basic(self):
|
||
"""Test format_user_friendly with basic options."""
|
||
t = Time(14, 30, 45)
|
||
|
||
# Without seconds
|
||
formatted = t.format_user_friendly(include_seconds=False)
|
||
assert formatted == "14:30"
|
||
|
||
# With seconds
|
||
formatted = t.format_user_friendly(include_seconds=True)
|
||
assert formatted == "14:30:45"
|
||
|
||
def test_format_user_friendly_with_timezone(self):
|
||
"""Test format_user_friendly with timezone."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
t = Time(14, 30, 45, tzinfo=berlin_tz)
|
||
|
||
# Auto-include timezone
|
||
formatted = t.format_user_friendly()
|
||
assert "14:30" in formatted
|
||
assert any(tz_indicator in formatted for tz_indicator in ["+", "-", "Z"])
|
||
|
||
def test_now_classmethod(self):
|
||
"""Test now() class method."""
|
||
now = Time.now()
|
||
assert isinstance(now, Time)
|
||
assert now.tzinfo is not None # Should have timezone info
|
||
|
||
# Test with specific timezone
|
||
utc_now = Time.now("UTC")
|
||
assert isinstance(utc_now, Time)
|
||
assert utc_now.tzinfo == pendulum.timezone("UTC")
|
||
|
||
def test_parse_classmethod(self):
|
||
"""Test parse() class method."""
|
||
time_str = "14:30:45"
|
||
parsed = Time.parse(time_str)
|
||
assert isinstance(parsed, Time)
|
||
assert parsed.hour == 14
|
||
assert parsed.minute == 30
|
||
assert parsed.second == 45
|
||
|
||
def test_in_timezone_conversion(self):
|
||
"""Test in_timezone method for actual timezone conversion."""
|
||
utc_tz = pendulum.timezone("UTC")
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
|
||
# Create UTC time
|
||
utc_time = Time(12, 0, 0, tzinfo=utc_tz)
|
||
|
||
# Convert to Berlin time
|
||
berlin_time = utc_time.in_timezone(berlin_tz)
|
||
assert isinstance(berlin_time, Time)
|
||
assert berlin_time.tzinfo == berlin_tz
|
||
# The actual hour will depend on DST, but it should be different from 12
|
||
# This is a simplified test - you may need to adjust based on actual conversion logic
|
||
|
||
def test_in_timezone_naive_time(self):
|
||
"""Test in_timezone with naive time."""
|
||
t = Time(14, 30, 0) # naive
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
|
||
result = t.in_timezone(berlin_tz)
|
||
assert isinstance(result, Time)
|
||
# Should assume local timezone and convert
|
||
|
||
def test_to_local(self):
|
||
"""Test to_local method."""
|
||
utc_tz = pendulum.timezone("UTC")
|
||
t = Time(12, 0, 0, tzinfo=utc_tz)
|
||
|
||
local_time = t.to_local()
|
||
assert isinstance(local_time, Time)
|
||
assert local_time.tzinfo == pendulum.local_timezone()
|
||
|
||
def test_to_utc(self):
|
||
"""Test to_utc method."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
t = Time(14, 0, 0, tzinfo=berlin_tz)
|
||
|
||
utc_time = t.to_utc()
|
||
assert isinstance(utc_time, Time)
|
||
assert utc_time.tzinfo == pendulum.timezone("UTC")
|
||
|
||
def test_create_from_pendulum_time(self):
|
||
"""Test _create_from_pendulum_time class method."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
pend_time = pendulum.time(14, 30, 45, 123456).replace(tzinfo=berlin_tz)
|
||
|
||
custom_time = Time._create_from_pendulum_time(pend_time)
|
||
assert isinstance(custom_time, Time)
|
||
assert custom_time.hour == 14
|
||
assert custom_time.minute == 30
|
||
assert custom_time.second == 45
|
||
assert custom_time.microsecond == 123456
|
||
assert custom_time.tzinfo == berlin_tz
|
||
|
||
|
||
# -------
|
||
# to_time
|
||
# -------
|
||
|
||
|
||
class TestToTime:
|
||
"""Test suite for the to_time function."""
|
||
|
||
def test_to_time_string_input(self):
|
||
"""Test to_time with string input."""
|
||
result = to_time("14:30:45")
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.second == 45
|
||
|
||
def test_to_time_time_object_input(self):
|
||
"""Test to_time with Time object input."""
|
||
t = Time(14, 30, 45)
|
||
result = to_time(t)
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.second == 45
|
||
|
||
def test_to_time_pendulum_time_input(self):
|
||
"""Test to_time with pendulum.Time input."""
|
||
pend_time = pendulum.time(14, 30, 45)
|
||
result = to_time(pend_time)
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.second == 45
|
||
|
||
def test_to_time_datetime_time_input(self):
|
||
"""Test to_time with datetime.time input."""
|
||
dt_time = datetime.time(14, 30, 45)
|
||
result = to_time(dt_time)
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.second == 45
|
||
|
||
def test_to_time_datetime_datetime_input(self):
|
||
"""Test to_time with datetime.datetime input."""
|
||
dt_datetime = datetime.datetime(2023, 10, 15, 14, 30, 45)
|
||
result = to_time(dt_datetime, in_timezone = "UTC")
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.second == 45
|
||
|
||
def test_to_time_integer_input(self):
|
||
"""Test to_time with integer input (hour only)."""
|
||
result = to_time(14)
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 0
|
||
assert result.second == 0
|
||
|
||
def test_to_time_float_input(self):
|
||
"""Test to_time with float input (decimal hours)."""
|
||
result = to_time(14.5) # 14:30
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.second == 0
|
||
|
||
def test_to_time_tuple_input(self):
|
||
"""Test to_time with tuple input."""
|
||
test_cases = [
|
||
((14,), 14, 0, 0, 0),
|
||
((14, 30), 14, 30, 0, 0),
|
||
((14, 30, 45), 14, 30, 45, 0),
|
||
((14, 30, 45, 123456), 14, 30, 45, 123456),
|
||
]
|
||
|
||
for tuple_input, expected_hour, expected_minute, expected_second, expected_microsecond in test_cases:
|
||
result = to_time(tuple_input)
|
||
assert isinstance(result, Time)
|
||
assert result.hour == expected_hour
|
||
assert result.minute == expected_minute
|
||
assert result.second == expected_second
|
||
assert result.microsecond == expected_microsecond
|
||
|
||
def test_to_time_with_timezone(self):
|
||
"""Test to_time with timezone parameter."""
|
||
result = to_time("14:30", in_timezone="Europe/Berlin")
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.tzinfo == pendulum.timezone("Europe/Berlin")
|
||
|
||
def test_to_time_to_naive(self):
|
||
"""Test to_time with to_naive=True."""
|
||
#result = to_time("14:30", in_timezone="Europe/Berlin", to_naive=True)
|
||
result = to_time("14:30", to_naive=True)
|
||
#result = to_time("14:30")
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.tzinfo is None
|
||
|
||
def test_to_time_as_string_true(self):
|
||
"""Test to_time with as_string=True."""
|
||
result = to_time("14:30:45", as_string=True)
|
||
assert isinstance(result, str)
|
||
assert "14:30:45" in result
|
||
|
||
def test_to_time_as_string_format(self):
|
||
"""Test to_time with custom format string."""
|
||
result = to_time("14:30:45", as_string="HH:mm")
|
||
assert isinstance(result, str)
|
||
assert result == "14:30"
|
||
|
||
def test_to_time_timezone_conversion(self):
|
||
"""Test to_time with timezone conversion."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
utc_tz = pendulum.timezone("UTC")
|
||
|
||
# Create time with Berlin timezone
|
||
berlin_time = pendulum.time(14, 30, 0).replace(tzinfo=berlin_tz)
|
||
|
||
# Convert to UTC
|
||
result = to_time(berlin_time, in_timezone="UTC")
|
||
assert isinstance(result, Time)
|
||
assert result.tzinfo == utc_tz
|
||
# The hour should be different due to timezone conversion
|
||
|
||
def test_to_time_invalid_timezone(self):
|
||
"""Test to_time with invalid timezone."""
|
||
with pytest.raises(ValueError, match="Invalid timezone"):
|
||
to_time("14:30", in_timezone="Invalid/Timezone")
|
||
|
||
def test_to_time_invalid_input_type(self):
|
||
"""Test to_time with invalid input type."""
|
||
with pytest.raises(ValueError, match="Unsupported type"):
|
||
to_time({"invalid": "input"})
|
||
|
||
def test_to_time_invalid_hour_integer(self):
|
||
"""Test to_time with invalid hour as integer."""
|
||
with pytest.raises(ValueError, match="Hour must be between 0 and 23"):
|
||
to_time(25)
|
||
|
||
def test_to_time_invalid_hour_float(self):
|
||
"""Test to_time with invalid hour as float."""
|
||
with pytest.raises(ValueError, match="Hour must be between 0 and 23"):
|
||
to_time(25.5)
|
||
|
||
def test_to_time_empty_tuple(self):
|
||
"""Test to_time with empty tuple."""
|
||
with pytest.raises(ValueError, match="Empty tuple provided"):
|
||
to_time(())
|
||
|
||
def test_to_time_pendulum_datetime_input(self):
|
||
"""Test to_time with pendulum DateTime input."""
|
||
dt = pendulum.datetime(2023, 10, 15, 14, 30, 45)
|
||
result = to_time(dt, in_timezone = "UTC")
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.second == 45
|
||
|
||
def test_to_time_with_timezone_object(self):
|
||
"""Test to_time with timezone object instead of string."""
|
||
berlin_tz = pendulum.timezone("Europe/Berlin")
|
||
result = to_time("14:30", in_timezone=berlin_tz)
|
||
assert isinstance(result, Time)
|
||
assert result.tzinfo == berlin_tz
|
||
|
||
def test_to_time_invalid_timezone_type(self):
|
||
"""Test to_time with invalid timezone type."""
|
||
with pytest.raises(ValueError, match="Invalid timezone"):
|
||
to_time("14:30", in_timezone=123)
|
||
|
||
def test_to_time_microseconds_precision(self):
|
||
"""Test to_time preserves microsecond precision."""
|
||
result = to_time("14:30:45.123456")
|
||
assert isinstance(result, Time)
|
||
assert result.microsecond == 123456
|
||
|
||
def test_to_time_fallback_parsing(self):
|
||
"""Test to_time fallback parsing mechanisms."""
|
||
# Test with a format that might not be caught by the main parser
|
||
# This tests the fallback to pendulum.parse
|
||
result = to_time("14:30:45")
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.second == 45
|
||
|
||
@patch('akkudoktoreos.utils.datetimeutil.logger.trace')
|
||
def test_to_time_logging_on_parse_failures(self, mock_trace):
|
||
"""Test that parsing failures are logged appropriately."""
|
||
# This test verifies that failed parsing attempts are logged
|
||
with pytest.raises(ValueError):
|
||
to_time("definitely_invalid_time_format")
|
||
|
||
# Verify that trace logs were called for failed parsing attempts
|
||
assert mock_trace.called
|
||
|
||
def test_to_time_timezone_aware_datetime_input(self):
|
||
"""Test to_time with timezone-aware datetime input."""
|
||
tz = datetime.timezone.utc
|
||
dt = datetime.datetime(2023, 10, 15, 14, 30, 45, tzinfo=tz)
|
||
result = to_time(dt)
|
||
assert isinstance(result, Time)
|
||
assert result.hour == 14
|
||
assert result.minute == 30
|
||
assert result.second == 45
|
||
assert result.tzinfo is not None
|
||
|
||
|
||
# ----------------
|
||
# to_time and Time
|
||
# ----------------
|
||
|
||
|
||
class TestTimeUtilityIntegration:
|
||
"""Integration tests for the time utility functions."""
|
||
|
||
def test_time_roundtrip_serialization(self):
|
||
"""Test that Time objects can be serialized and deserialized."""
|
||
original = Time(14, 30, 45, 123456, tzinfo=pendulum.timezone("Europe/Berlin"))
|
||
|
||
# Serialize
|
||
serialized = Time._serialize(original)
|
||
assert serialized == "14:30:45.123456 Europe/Berlin"
|
||
|
||
# Parse back
|
||
parsed = Time.parse(serialized)
|
||
|
||
assert parsed.hour == original.hour
|
||
assert parsed.minute == original.minute
|
||
assert parsed.second == original.second
|
||
assert parsed.microsecond == original.microsecond
|
||
|
||
def test_time_pydantic_integration(self):
|
||
"""Test Time class integration with Pydantic models."""
|
||
class TestModel(PydanticBaseModel):
|
||
test_time: Time
|
||
|
||
# Test with string input
|
||
model = TestModel(test_time="14:30:45")
|
||
assert isinstance(model.test_time, Time)
|
||
assert model.test_time.hour == 14
|
||
|
||
|
||
def test_time_class_uses_to_time_logic(self):
|
||
"""Test that Time class validation uses the same logic as to_time."""
|
||
# Test with various inputs that both should handle identically
|
||
test_cases = [
|
||
"14:30",
|
||
14.5,
|
||
(14, 30),
|
||
datetime.time(14, 30),
|
||
pendulum.time(14, 30)
|
||
]
|
||
|
||
class TestModel(PydanticBaseModel):
|
||
test_time: Time
|
||
|
||
for case in test_cases:
|
||
# Both should produce the same result
|
||
direct_result = to_time(case)
|
||
model_result = TestModel(test_time=case).test_time
|
||
|
||
assert direct_result.hour == model_result.hour
|
||
assert direct_result.minute == model_result.minute
|
||
assert direct_result.second == model_result.second
|
||
|
||
|
||
# ------------------------------------
|
||
# date and time types used in pydantic
|
||
# ------------------------------------
|
||
|
||
class ScheduleModel(PydanticBaseModel):
|
||
start_time: Time
|
||
run_duration: Duration
|
||
scheduled_at: DateTime
|
||
run_on: Date
|
||
|
||
|
||
class TestPendulumTypes:
|
||
|
||
def test_valid_schedule_model(self):
|
||
model = ScheduleModel(
|
||
start_time="14:30:00",
|
||
run_duration=to_duration("PT2H"),
|
||
scheduled_at=to_datetime("2025-07-04T09:00:00+02:00"),
|
||
run_on=to_datetime("2025-07-04")
|
||
)
|
||
|
||
assert isinstance(model.start_time, pendulum.Time)
|
||
assert isinstance(model.run_duration, pendulum.Duration)
|
||
assert isinstance(model.scheduled_at, pendulum.DateTime)
|
||
assert isinstance(model.run_on, pendulum.Date)
|
||
|
||
assert model.start_time.hour == 14
|
||
assert model.run_duration.in_hours() == 2
|
||
assert model.scheduled_at.to_date_string() == "2025-07-04"
|
||
assert model.run_on.to_date_string() == "2025-07-04"
|
||
|
||
def test_json_serialization(self):
|
||
model = ScheduleModel(
|
||
start_time=pendulum.time(6, 15),
|
||
run_duration=pendulum.duration(minutes=45),
|
||
scheduled_at=pendulum.datetime(2025, 7, 4, 6, 15, tz="Europe/Berlin"),
|
||
run_on=pendulum.date(2025, 7, 4)
|
||
)
|
||
|
||
json_data = model.model_dump(mode="json")
|
||
assert "06:15:00" in json_data["start_time"]
|
||
assert "PT45M" in json_data["run_duration"]
|
||
assert "2025-07-04T06:15:00" in json_data["scheduled_at"]
|
||
assert "2025-07-04" in json_data["run_on"]
|
||
|
||
json_str = model.model_dump_json()
|
||
assert '"06:15:00' in json_str
|
||
assert "45 minutes" in json_str
|
||
assert "2025-07-04 06:15:00" in json_str
|
||
assert '"2025-07-04"' in json_str
|
||
|
||
def test_invalid_start_time(self):
|
||
with pytest.raises(ValidationError):
|
||
ScheduleModel(
|
||
start_time="invalid",
|
||
run_duration="PT1H",
|
||
scheduled_at="2025-07-04T09:00:00+02:00",
|
||
run_on="2025-07-04"
|
||
)
|
||
|
||
def test_invalid_duration(self):
|
||
with pytest.raises(ValidationError):
|
||
ScheduleModel(
|
||
start_time="10:00:00",
|
||
run_duration="2 hours", # invalid ISO 8601 duration
|
||
scheduled_at="2025-07-04T09:00:00+02:00",
|
||
run_on="2025-07-04"
|
||
)
|
||
|
||
def test_type_coercion(self):
|
||
dt = pendulum.datetime(2025, 7, 4, 12, 0)
|
||
model = ScheduleModel(
|
||
start_time=pendulum.time(12, 0),
|
||
run_duration=pendulum.duration(hours=3),
|
||
scheduled_at=dt,
|
||
run_on=dt.date()
|
||
)
|
||
assert model.scheduled_at.hour == 12
|
||
assert model.run_duration.total_minutes() == 180
|
||
|
||
|
||
# -----------------------------
|
||
# to_datetime
|
||
# -----------------------------
|
||
|
||
|
||
# Test cases for valid pendulum.duration inputs
|
||
@pytest.mark.parametrize(
|
||
"test_case, local_timezone, date_input, as_string, in_timezone, to_naiv, to_maxtime, expected_output, expected_approximately",
|
||
[
|
||
# ---------------------------------------
|
||
# from string to pendulum.datetime object
|
||
# ---------------------------------------
|
||
# - no timezone
|
||
(
|
||
"TC001",
|
||
"Etc/UTC",
|
||
"2024-01-01",
|
||
None,
|
||
None,
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 1, 1, 0, 0, 0, tz="Etc/UTC"),
|
||
False,
|
||
),
|
||
(
|
||
"TC002",
|
||
"Europe/Berlin",
|
||
"2024-01-01",
|
||
None,
|
||
None,
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 1, 1, 0, 0, 0, tz="Europe/Berlin"),
|
||
False,
|
||
),
|
||
(
|
||
"TC003",
|
||
"Europe/Berlin",
|
||
"2024-01-01",
|
||
None,
|
||
None,
|
||
None,
|
||
False,
|
||
pendulum.datetime(2023, 12, 31, 23, 0, 0, tz="Etc/UTC"),
|
||
False,
|
||
),
|
||
(
|
||
"TC004",
|
||
"Europe/Paris",
|
||
"2024-01-01 00:00:00",
|
||
None,
|
||
None,
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 1, 1, 0, 0, 0, tz="Europe/Paris"),
|
||
False,
|
||
),
|
||
(
|
||
"TC005",
|
||
"Etc/UTC",
|
||
"2024-01-01 00:00:00",
|
||
None,
|
||
None,
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 1, 1, 1, 0, 0, tz="Europe/Berlin"),
|
||
False,
|
||
),
|
||
(
|
||
"TC006",
|
||
"Europe/Berlin",
|
||
"2024-01-01 00:00:00",
|
||
None,
|
||
None,
|
||
None,
|
||
False,
|
||
pendulum.datetime(2023, 12, 31, 23, 0, 0, tz="Etc/UTC"),
|
||
False,
|
||
),
|
||
(
|
||
"TC007",
|
||
"Atlantic/Canary",
|
||
"2024-01-01 12:00:00",
|
||
None,
|
||
None,
|
||
None,
|
||
False,
|
||
pendulum.datetime(
|
||
2024,
|
||
1,
|
||
1,
|
||
12,
|
||
0,
|
||
0,
|
||
tz="Atlantic/Canary",
|
||
),
|
||
False,
|
||
),
|
||
(
|
||
"TC008",
|
||
"Etc/UTC",
|
||
"2024-01-01 12:00:00",
|
||
None,
|
||
None, # force local timezone
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 1, 1, 13, 0, 0, tz="Europe/Berlin"),
|
||
False,
|
||
),
|
||
(
|
||
"TC009",
|
||
"Europe/Berlin",
|
||
"2024-01-01 12:00:00",
|
||
None,
|
||
None,
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 1, 1, 11, 0, 0, tz="Etc/UTC"),
|
||
False,
|
||
),
|
||
# - with timezone
|
||
(
|
||
"TC010",
|
||
"Etc/UTC",
|
||
"02/02/24",
|
||
None,
|
||
"Europe/Berlin",
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 2, 2, 0, 0, 0, tz="Europe/Berlin"),
|
||
False,
|
||
),
|
||
(
|
||
"TC011",
|
||
"Etc/UTC",
|
||
"2024-03-03T10:20:30.000+01:00", # No dalight saving time at this date
|
||
None,
|
||
"Europe/Berlin",
|
||
None,
|
||
None,
|
||
pendulum.datetime(2024, 3, 3, 10, 20, 30, 0, tz="Europe/Berlin"),
|
||
False,
|
||
),
|
||
(
|
||
"TC012",
|
||
"Etc/UTC",
|
||
"2024-04-04T10:20:30.000+02:00",
|
||
None,
|
||
"Europe/Berlin",
|
||
False,
|
||
None,
|
||
pendulum.datetime(2024, 4, 4, 10, 20, 30, 0, tz="Europe/Berlin"),
|
||
False,
|
||
),
|
||
(
|
||
"TC013",
|
||
"Etc/UTC",
|
||
"2024-05-05T10:20:30.000+02:00",
|
||
None,
|
||
"Europe/Berlin",
|
||
True,
|
||
None,
|
||
pendulum.naive(2024, 5, 5, 10, 20, 30, 0),
|
||
False,
|
||
),
|
||
# - without local timezone as UTC
|
||
(
|
||
"TC014",
|
||
"UTC",
|
||
"2024-01-03",
|
||
None,
|
||
"UTC",
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 1, 3, 0, 0, 0, tz="UTC"),
|
||
False,
|
||
),
|
||
(
|
||
"TC015",
|
||
"Atlantic/Canary",
|
||
"02/02/24",
|
||
None,
|
||
"UTC",
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 2, 2, 0, 0, 0, tz="UTC"),
|
||
False,
|
||
),
|
||
(
|
||
"TC016",
|
||
"Atlantic/Canary",
|
||
"2024-03-03T10:20:30.000Z", # No dalight saving time at this date
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
pendulum.datetime(2024, 3, 3, 10, 20, 30, 0, tz="UTC"),
|
||
False,
|
||
),
|
||
# ---------------------------------------
|
||
# from pendulum.datetime to pendulum.datetime object
|
||
# ---------------------------------------
|
||
(
|
||
"TC017",
|
||
"Atlantic/Canary",
|
||
pendulum.datetime(2024, 4, 4, 0, 0, 0),
|
||
None,
|
||
None,
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 4, 4, 0, 0, 0, tz="Etc/UTC"),
|
||
False,
|
||
),
|
||
(
|
||
"TC018",
|
||
"Atlantic/Canary",
|
||
pendulum.datetime(2024, 4, 4, 1, 0, 0),
|
||
None,
|
||
"Europe/Berlin",
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 4, 4, 3, 0, 0, tz="Europe/Berlin"),
|
||
False,
|
||
),
|
||
(
|
||
"TC019",
|
||
"Atlantic/Canary",
|
||
pendulum.datetime(2024, 4, 4, 1, 0, 0, tz="Etc/UTC"),
|
||
None,
|
||
"Europe/Berlin",
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 4, 4, 3, 0, 0, tz="Europe/Berlin"),
|
||
False,
|
||
),
|
||
(
|
||
"TC020",
|
||
"Atlantic/Canary",
|
||
pendulum.datetime(2024, 4, 4, 2, 0, 0, tz="Europe/Berlin"),
|
||
None,
|
||
"Etc/UTC",
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 4, 4, 0, 0, 0, tz="Etc/UTC"),
|
||
False,
|
||
),
|
||
# ---------------------------------------
|
||
# from string to UTC string
|
||
# ---------------------------------------
|
||
# - no timezone
|
||
# local timezone UTC
|
||
(
|
||
"TC021",
|
||
"Etc/UTC",
|
||
"2023-11-06T00:00:00",
|
||
"UTC",
|
||
None,
|
||
None,
|
||
None,
|
||
"2023-11-06T00:00:00Z",
|
||
False,
|
||
),
|
||
# local timezone "Europe/Berlin"
|
||
(
|
||
"TC022",
|
||
"Europe/Berlin",
|
||
"2023-11-06T00:00:00",
|
||
"UTC",
|
||
"Europe/Berlin",
|
||
None,
|
||
None,
|
||
"2023-11-05T23:00:00Z",
|
||
False,
|
||
),
|
||
# - no microseconds
|
||
(
|
||
"TC023",
|
||
"Atlantic/Canary",
|
||
"2024-10-30T00:00:00+01:00",
|
||
"UTC",
|
||
None,
|
||
None,
|
||
None,
|
||
"2024-10-29T23:00:00Z",
|
||
False,
|
||
),
|
||
(
|
||
"TC024",
|
||
"Atlantic/Canary",
|
||
"2024-10-30T01:00:00+01:00",
|
||
"utc",
|
||
None,
|
||
None,
|
||
None,
|
||
"2024-10-30T00:00:00Z",
|
||
False,
|
||
),
|
||
# - with microseconds
|
||
(
|
||
"TC025",
|
||
"Atlantic/Canary",
|
||
"2024-10-07T10:20:30.000+02:00",
|
||
"UTC",
|
||
None,
|
||
None,
|
||
None,
|
||
"2024-10-07T08:20:30Z",
|
||
False,
|
||
),
|
||
# ---------------------------------------
|
||
# from None to pendulum.datetime object
|
||
# ---------------------------------------
|
||
# - no timezone
|
||
# local timezone
|
||
(
|
||
"TC026",
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
pendulum.now(),
|
||
True,
|
||
),
|
||
],
|
||
)
|
||
def test_to_datetime(
|
||
set_other_timezone,
|
||
test_case,
|
||
local_timezone,
|
||
date_input,
|
||
as_string,
|
||
in_timezone,
|
||
to_naiv,
|
||
to_maxtime,
|
||
expected_output,
|
||
expected_approximately,
|
||
):
|
||
"""Test pendulum.datetime conversion with valid inputs."""
|
||
set_other_timezone(local_timezone)
|
||
result = to_datetime(
|
||
date_input,
|
||
as_string=as_string,
|
||
in_timezone=in_timezone,
|
||
to_naiv=to_naiv,
|
||
to_maxtime=to_maxtime,
|
||
)
|
||
# if isinstance(date_input, str):
|
||
# print(f"Input: {date_input}")
|
||
# else:
|
||
# print(f"Input: {date_input} tz={date_input.timezone}")
|
||
if isinstance(expected_output, str):
|
||
# print(f"Expected: {expected_output}")
|
||
# print(f"Result: {result}")
|
||
assert result == expected_output
|
||
elif expected_output.timezone is None:
|
||
# We expect an exception
|
||
with pytest.raises(TypeError):
|
||
assert compare_datetimes(result, expected_output).equal
|
||
else:
|
||
compare = compare_datetimes(result, expected_output)
|
||
# print(f"---- Testcase: {test_case} ----")
|
||
# print(f"Expected: {expected_output} tz={expected_output.timezone}")
|
||
# print(f"Result: {result} tz={result.timezone}")
|
||
# print(f"Compare: {compare}")
|
||
if expected_approximately:
|
||
# Compare using Unix timestamps to avoid pendulum timezone state
|
||
# issues and to prevent staleness from parametrize collection time.
|
||
import time
|
||
|
||
assert abs(result.timestamp() - time.time()) < 300
|
||
else:
|
||
assert compare.equal == True
|
||
|
||
|
||
# -----------------------------
|
||
# to_duration
|
||
# -----------------------------
|
||
|
||
class TestToDuration:
|
||
# ------------------------------------------------------------------
|
||
# Valid input conversions (no formatting)
|
||
# ------------------------------------------------------------------
|
||
@pytest.mark.parametrize(
|
||
"input_value, expected_output",
|
||
[
|
||
# duration input
|
||
(pendulum.duration(days=1), pendulum.duration(days=1)),
|
||
|
||
# String input
|
||
("1 hour", pendulum.duration(hours=1)),
|
||
("2 days", pendulum.duration(days=2)),
|
||
("5 hours", pendulum.duration(hours=5)),
|
||
("47 hours", pendulum.duration(hours=47)),
|
||
("48 hours", pendulum.duration(seconds=48 * 3600)),
|
||
("30 minutes", pendulum.duration(minutes=30)),
|
||
("45 seconds", pendulum.duration(seconds=45)),
|
||
(
|
||
"1 day 2 hours 30 minutes 15 seconds",
|
||
pendulum.duration(days=1, hours=2, minutes=30, seconds=15),
|
||
),
|
||
("3 days 4 hours", pendulum.duration(days=3, hours=4)),
|
||
|
||
# Integer / Float
|
||
(3600, pendulum.duration(seconds=3600)),
|
||
(86400, pendulum.duration(days=1)),
|
||
(1800.5, pendulum.duration(seconds=1800.5)),
|
||
|
||
# Tuple / List
|
||
((1, 2, 30, 15), pendulum.duration(days=1, hours=2, minutes=30, seconds=15)),
|
||
([0, 10, 0, 0], pendulum.duration(hours=10)),
|
||
],
|
||
)
|
||
def test_to_duration_valid(self, input_value, expected_output):
|
||
"""Test that valid inputs convert to correct Duration objects."""
|
||
assert to_duration(input_value) == expected_output
|
||
|
||
# ------------------------------------------------------------------
|
||
# ISO-8601 output (`as_string=True`)
|
||
# ------------------------------------------------------------------
|
||
@pytest.mark.parametrize(
|
||
"input_value, expected",
|
||
[
|
||
("15 minutes", "PT15M"),
|
||
("1 hour 30 minutes", "PT1H30M"),
|
||
("45 seconds", "PT45S"),
|
||
("1 hour 5 seconds", "PT1H5S"),
|
||
("2 days", "P2D"),
|
||
("2 days 3 hours 4 minutes 5 seconds", "P2DT3H4M5S"),
|
||
("0 seconds", "PT0S"),
|
||
]
|
||
)
|
||
def test_as_string_true_iso8601(self, input_value, expected):
|
||
"""Test ISO-8601 duration strings for various inputs."""
|
||
assert to_duration(input_value, as_string=True) == expected
|
||
|
||
# ------------------------------------------------------------------
|
||
# Human readable (`as_string="human"`)
|
||
# ------------------------------------------------------------------
|
||
def test_as_string_human(self):
|
||
assert to_duration("90 seconds", as_string="human") == "1 minute 30 seconds"
|
||
|
||
# ------------------------------------------------------------------
|
||
# Pandas frequency (`as_string="pandas"`)
|
||
# ------------------------------------------------------------------
|
||
@pytest.mark.parametrize(
|
||
"input_value, expected",
|
||
[
|
||
("1 hour", "1h"),
|
||
("2 hours", "2h"),
|
||
("15 minutes", "15min"),
|
||
("90 minutes", "90min"),
|
||
("30 seconds", "30s"),
|
||
("900 seconds", "15min"),
|
||
],
|
||
)
|
||
def test_as_string_pandas(self, input_value, expected):
|
||
assert to_duration(input_value, as_string="pandas") == expected
|
||
|
||
# ------------------------------------------------------------------
|
||
# Custom format strings
|
||
# ------------------------------------------------------------------
|
||
def test_as_string_custom_seconds(self):
|
||
assert to_duration("75 seconds", as_string="Total: {S}s") == "Total: 75s"
|
||
|
||
def test_as_string_custom_minutes(self):
|
||
assert to_duration("15 minutes", as_string="{M}m total") == "15m total"
|
||
|
||
def test_as_string_custom_hours(self):
|
||
assert to_duration("7200 seconds", as_string="{H} hours") == "2 hours"
|
||
|
||
def test_as_string_custom_human_alias(self):
|
||
assert to_duration("30 minutes", as_string="{f}") == "30 minutes"
|
||
|
||
# ------------------------------------------------------------------
|
||
# Invalid input handling
|
||
# ------------------------------------------------------------------
|
||
@pytest.mark.parametrize(
|
||
"input_value",
|
||
[
|
||
"not a duration",
|
||
"5 lightyears",
|
||
(1, 2, 3), # wrong tuple size
|
||
{"a": 1}, # unsupported type
|
||
None,
|
||
],
|
||
)
|
||
def test_invalid_inputs_raise(self, input_value):
|
||
with pytest.raises(ValueError):
|
||
to_duration(input_value)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Invalid as_string values
|
||
# ------------------------------------------------------------------
|
||
def test_invalid_as_string_raises(self):
|
||
with pytest.raises(ValueError):
|
||
to_duration("5 minutes", as_string=123) # type: ignore
|
||
|
||
def test_summation(self):
|
||
start_datetime = to_datetime("2028-01-11 00:00:00")
|
||
index_datetime = start_datetime
|
||
for i in range(48):
|
||
expected_datetime = start_datetime + to_duration(f"{i} hours")
|
||
assert index_datetime == expected_datetime
|
||
index_datetime += to_duration("1 hour")
|
||
assert index_datetime == to_datetime("2028-01-13 00:00:00")
|
||
|
||
def test_excessive_length_raises_valueerror(self):
|
||
"""Test that to_duration raises ValueError for strings exceeding max length.
|
||
|
||
This test covers the fix for the ReDoS vulnerability.
|
||
Related to: #494
|
||
"""
|
||
# String exceeds limits
|
||
long_string = "a" * (MAX_DURATION_STRING_LENGTH + 50)
|
||
|
||
# Expected Errormessage – ESCAPED für Regex
|
||
expected_error_message = re.escape(
|
||
f"Input string exceeds maximum allowed length ({MAX_DURATION_STRING_LENGTH})."
|
||
)
|
||
|
||
# Check if error was raised
|
||
with pytest.raises(ValueError, match=expected_error_message):
|
||
to_duration(long_string)
|
||
|
||
# Optional: String exactly at the limit should NOT trigger the length check.
|
||
at_limit_string = "b" * MAX_DURATION_STRING_LENGTH
|
||
try:
|
||
to_duration(at_limit_string)
|
||
except ValueError as e:
|
||
if str(e) == f"Input string exceeds maximum allowed length ({MAX_DURATION_STRING_LENGTH}).":
|
||
pytest.fail(
|
||
f"to_duration raised length ValueError unexpectedly for string at limit: {at_limit_string}"
|
||
)
|
||
pass
|
||
|
||
|
||
# -----------------------------
|
||
# to_timezone
|
||
# -----------------------------
|
||
|
||
|
||
def test_to_timezone_string():
|
||
"""Test to_timezone function returns correct timezone as a string."""
|
||
location = (40.7128, -74.0060) # New York City coordinates
|
||
result = to_timezone(location=location, as_string=True)
|
||
assert result == "America/New_York", "Expected timezone string 'America/New_York'"
|
||
|
||
|
||
def test_to_timezone_timezone():
|
||
"""Test to_timezone function returns correct timezone as a Timezone object."""
|
||
location = (40.7128, -74.0060) # New York City coordinates
|
||
result = to_timezone(location=location)
|
||
assert isinstance(result, Timezone), "Expected a Timezone object"
|
||
assert result.name == "America/New_York", "Expected Timezone name 'America/New_York'"
|
||
|
||
|
||
def test_to_timezone_invalid_coordinates():
|
||
"""Test to_timezone function handles invalid coordinates gracefully."""
|
||
location = (100.0, 200.0) # Invalid coordinates outside Earth range
|
||
with pytest.raises(ValueError, match="Invalid latitude/longitude"):
|
||
to_timezone(location=location, as_string=True)
|
||
|
||
|
||
# -----------------------------
|
||
# hours_in_day
|
||
# -----------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"local_timezone, date, in_timezone, expected_hours",
|
||
[
|
||
("Etc/UTC", "2024-11-10 00:00:00", "Europe/Berlin", 24), # No DST in Germany
|
||
("Etc/UTC", "2024-08-10 00:00:00", "Europe/Berlin", 24), # DST in Germany
|
||
("Etc/UTC", "2024-03-31 00:00:00", "Europe/Berlin", 23), # DST change (23 hours/ day)
|
||
("Etc/UTC", "2024-10-27 00:00:00", "Europe/Berlin", 25), # DST change (25 hours/ day)
|
||
("Europe/Berlin", "2024-11-10 00:00:00", "Europe/Berlin", 24), # No DST in Germany
|
||
("Europe/Berlin", "2024-08-10 00:00:00", "Europe/Berlin", 24), # DST in Germany
|
||
("Europe/Berlin", "2024-03-31 00:00:00", "Europe/Berlin", 23), # DST change (23 hours/ day)
|
||
("Europe/Berlin", "2024-10-27 00:00:00", "Europe/Berlin", 25), # DST change (25 hours/ day)
|
||
],
|
||
)
|
||
def test_hours_in_day(set_other_timezone, local_timezone, date, in_timezone, expected_hours):
|
||
"""Test the `test_hours_in_day` function."""
|
||
set_other_timezone(local_timezone)
|
||
date_input = to_datetime(date, in_timezone=in_timezone)
|
||
assert date_input.timezone.name == in_timezone
|
||
assert hours_in_day(date_input) == expected_hours
|
||
|
||
|
||
# -----------------------------
|
||
# compare_datetimes
|
||
# -----------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"dt1, dt2, equal, ge, gt, le, lt",
|
||
[
|
||
# Same time in the same timezone
|
||
(
|
||
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
|
||
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
),
|
||
(
|
||
pendulum.datetime(2024, 4, 4, 0, 0, 0, tz="Europe/Berlin"),
|
||
pendulum.datetime(2024, 4, 4, 0, 0, 0, tz="Europe/Berlin"),
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
),
|
||
# Same instant in different timezones (converted to UTC)
|
||
(
|
||
pendulum.datetime(2024, 3, 15, 8, 0, 0, tz="Europe/Berlin"),
|
||
pendulum.datetime(2024, 3, 15, 7, 0, 0, tz="UTC"),
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
),
|
||
# Different times across timezones (converted to UTC)
|
||
(
|
||
pendulum.datetime(2024, 3, 15, 8, 0, 0, tz="America/New_York"),
|
||
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
|
||
True,
|
||
True,
|
||
False,
|
||
True,
|
||
False,
|
||
),
|
||
],
|
||
)
|
||
def test_compare_datetimes_equal(dt1, dt2, equal, ge, gt, le, lt):
|
||
# requal = compare_datetimes(dt1, dt2).equal
|
||
# rgt = compare_datetimes(dt1, dt2).gt
|
||
# rge = compare_datetimes(dt1, dt2).ge
|
||
# rlt = compare_datetimes(dt1, dt2).lt
|
||
# rle = compare_datetimes(dt1, dt2).le
|
||
# print(f"{dt1} vs. {dt2}: expected equal={equal}, ge={ge}, gt={gt}, le={le}, lt={lt}")
|
||
# print(f"{dt1} vs. {dt2}: result equal={requal}, ge={rge}, gt={rgt}, le={rle}, lt={rlt}")
|
||
assert compare_datetimes(dt1, dt2).equal == equal
|
||
assert compare_datetimes(dt1, dt2).ge == ge
|
||
assert compare_datetimes(dt1, dt2).gt == gt
|
||
assert compare_datetimes(dt1, dt2).le == le
|
||
assert compare_datetimes(dt1, dt2).lt == lt
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"dt1, dt2, equal, ge, gt, le, lt",
|
||
[
|
||
# Different times in the same timezone
|
||
(
|
||
pendulum.datetime(2024, 3, 15, 11, 0, 0, tz="UTC"),
|
||
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
),
|
||
# Different times across timezones (converted to UTC)
|
||
(
|
||
pendulum.datetime(2024, 3, 15, 6, 0, 0, tz="America/New_York"),
|
||
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
),
|
||
# DST changes: spring forward
|
||
(
|
||
pendulum.datetime(2024, 3, 10, 1, 59, 0, tz="America/New_York"),
|
||
pendulum.datetime(2024, 3, 10, 3, 0, 0, tz="America/New_York"),
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
),
|
||
# DST changes: fall back
|
||
(
|
||
pendulum.datetime(2024, 11, 3, 1, 0, 0, tz="America/New_York"),
|
||
pendulum.datetime(2024, 11, 3, 1, 30, 0, tz="America/New_York"),
|
||
False,
|
||
False,
|
||
False,
|
||
True,
|
||
True,
|
||
),
|
||
],
|
||
)
|
||
def test_compare_datetimes_lt(dt1, dt2, equal, ge, gt, le, lt):
|
||
# requal = compare_datetimes(dt1, dt2).equal
|
||
# rgt = compare_datetimes(dt1, dt2).gt
|
||
# rge = compare_datetimes(dt1, dt2).ge
|
||
# rlt = compare_datetimes(dt1, dt2).lt
|
||
# rle = compare_datetimes(dt1, dt2).le
|
||
# print(f"{dt1} vs. {dt2}: expected equal={equal}, ge={ge}, gt={gt}, le={le}, lt={lt}")
|
||
# print(f"{dt1} vs. {dt2}: result equal={requal}, ge={rge}, gt={rgt}, le={rle}, lt={rlt}")
|
||
assert compare_datetimes(dt1, dt2).equal == equal
|
||
assert compare_datetimes(dt1, dt2).ge == ge
|
||
assert compare_datetimes(dt1, dt2).gt == gt
|
||
assert compare_datetimes(dt1, dt2).le == le
|
||
assert compare_datetimes(dt1, dt2).lt == lt
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"dt1, dt2",
|
||
[
|
||
# Different times in the same timezone
|
||
(
|
||
pendulum.datetime(2024, 3, 15, 13, 0, 0, tz="UTC"),
|
||
pendulum.datetime(2024, 3, 15, 12, 0, 0, tz="UTC"),
|
||
),
|
||
],
|
||
)
|
||
def test_compare_datetimes_gt(dt1, dt2):
|
||
# requal = compare_datetimes(dt1, dt2).equal
|
||
# rgt = compare_datetimes(dt1, dt2).gt
|
||
# rge = compare_datetimes(dt1, dt2).ge
|
||
# rlt = compare_datetimes(dt1, dt2).lt
|
||
# rle = compare_datetimes(dt1, dt2).le
|
||
# print(f"{dt1} vs. {dt2}: expected equal={equal}, ge={ge}, gt={gt}, le={le}, lt={lt}")
|
||
# print(f"{dt1} vs. {dt2}: result equal={requal}, ge={rge}, gt={rgt}, le={rle}, lt={rlt}")
|
||
assert compare_datetimes(dt1, dt2).equal == False
|
||
assert compare_datetimes(dt1, dt2).ge
|
||
assert compare_datetimes(dt1, dt2).gt
|
||
assert compare_datetimes(dt1, dt2).le == False
|
||
assert compare_datetimes(dt1, dt2).lt == False
|