mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-10-29 13:56:21 +00:00
This fix implements the long term goal to have the EOS server run optimization (or
energy management) on regular intervals automatically. Thus clients can request
the current energy management plan at any time and it is updated on regular
intervals without interaction by the client.
This fix started out to "only" make automatic optimization (or energy management)
runs working. It turned out there are several endpoints that in some way
update predictions or run the optimization. To lock against such concurrent attempts
the code had to be refactored to allow control of execution. During refactoring it
became clear that some classes and files are named without a proper reference
to their usage. Thus not only refactoring but also renaming became necessary.
The names are still not the best, but I hope they are more intuitive.
The fix includes several bug fixes that are not directly related to the automatic optimization
but are necessary to keep EOS running properly to do the automatic optimization and
to test and document the changes.
This is a breaking change as the configuration structure changed once again and
the server API was also enhanced and streamlined. The server API that is used by
Andreas and Jörg in their videos has not changed.
* fix: automatic optimization
Allow optimization to automatically run on configured intervals gathering all
optimization parameters from configuration and predictions. The automatic run
can be configured to only run prediction updates skipping the optimization.
Extend documentaion to also cover automatic optimization. Lock automatic runs
against runs initiated by the /optimize or other endpoints. Provide new
endpoints to retrieve the energy management plan and the genetic solution
of the latest automatic optimization run. Offload energy management to thread
pool executor to keep the app more responsive during the CPU heavy optimization
run.
* fix: EOS servers recognize environment variables on startup
Force initialisation of EOS configuration on server startup to assure
all sources of EOS configuration are properly set up and read. Adapt
server tests and configuration tests to also test for environment
variable configuration.
* fix: Remove 0.0.0.0 to localhost translation under Windows
EOS imposed a 0.0.0.0 to localhost translation under Windows for
convenience. This caused some trouble in user configurations. Now, as the
default IP address configuration is 127.0.0.1, the user is responsible
for to set up the correct Windows compliant IP address.
* fix: allow names for hosts additional to IP addresses
* fix: access pydantic model fields by class
Access by instance is deprecated.
* fix: down sampling key_to_array
* fix: make cache clear endpoint clear all cache files
Make /v1/admin/cache/clear clear all cache files. Before it only cleared
expired cache files by default. Add new endpoint /v1/admin/clear-expired
to only clear expired cache files.
* fix: timezonefinder returns Europe/Paris instead of Europe/Berlin
timezonefinder 8.10 got more inaccurate for timezones in europe as there is
a common timezone. Use new package tzfpy instead which is still returning
Europe/Berlin if you are in Germany. tzfpy also claims to be faster than
timezonefinder.
* fix: provider settings configuration
Provider configuration used to be a union holding the settings for several
providers. Pydantic union handling does not always find the correct type
for a provider setting. This led to exceptions in specific configurations.
Now provider settings are explicit comfiguration items for each possible
provider. This is a breaking change as the configuration structure was
changed.
* fix: ClearOutside weather prediction irradiance calculation
Pvlib needs a pandas time index. Convert time index.
* fix: test config file priority
Do not use config_eos fixture as this fixture already creates a config file.
* fix: optimization sample request documentation
Provide all data in documentation of optimization sample request.
* fix: gitlint blocking pip dependency resolution
Replace gitlint by commitizen. Gitlint is not actively maintained anymore.
Gitlint dependencies blocked pip from dependency resolution.
* fix: sync pre-commit config to actual dependency requirements
.pre-commit-config.yaml was out of sync, also requirements-dev.txt.
* fix: missing babel in requirements.txt
Add babel to requirements.txt
* feat: setup default device configuration for automatic optimization
In case the parameters for automatic optimization are not fully defined a
default configuration is setup to allow the automatic energy management
run. The default configuration may help the user to correctly define
the device configuration.
* feat: allow configuration of genetic algorithm parameters
The genetic algorithm parameters for number of individuals, number of
generations, the seed and penalty function parameters are now avaliable
as configuration options.
* feat: allow configuration of home appliance time windows
The time windows a home appliance is allowed to run are now configurable
by the configuration (for /v1 API) and also by the home appliance parameters
(for the classic /optimize API). If there is no such configuration the
time window defaults to optimization hours, which was the standard before
the change. Documentation on how to configure time windows is added.
* feat: standardize mesaurement keys for battery/ ev SoC measurements
The standardized measurement keys to report battery SoC to the device
simulations can now be retrieved from the device configuration as a
read-only config option.
* feat: feed in tariff prediction
Add feed in tarif predictions needed for automatic optimization. The feed in
tariff can be retrieved as fixed feed in tarif or can be imported. Also add
tests for the different feed in tariff providers. Extend documentation to
cover the feed in tariff providers.
* feat: add energy management plan based on S2 standard instructions
EOS can generate an energy management plan as a list of simple instructions.
May be retrieved by the /v1/energy-management/plan endpoint. The instructions
loosely follow the S2 energy management standard.
* feat: make measurement keys configurable by EOS configuration.
The fixed measurement keys are replaced by configurable measurement keys.
* feat: make pendulum DateTime, Date, Duration types usable for pydantic models
Use pydantic_extra_types.pendulum_dt to get pydantic pendulum types. Types are
added to the datetimeutil utility. Remove custom made pendulum adaptations
from EOS pydantic module. Make EOS modules use the pydantic pendulum types
managed by the datetimeutil module instead of the core pendulum types.
* feat: Add Time, TimeWindow, TimeWindowSequence and to_time to datetimeutil.
The time windows are are added to support home appliance time window
configuration. All time classes are also pydantic models. Time is the base
class for time definition derived from pendulum.Time.
* feat: Extend DataRecord by configurable field like data.
Configurable field like data was added to support the configuration of
measurement records.
* feat: Add additional information to health information
Version information is added to the health endpoints of eos and eosDash.
The start time of the last optimization and the latest run time of the energy
management is added to the EOS health information.
* feat: add pydantic merge model tests
* feat: add plan tab to EOSdash
The plan tab displays the current energy management instructions.
* feat: add predictions tab to EOSdash
The predictions tab displays the current predictions.
* feat: add cache management to EOSdash admin tab
The admin tab is extended by a section for cache management. It allows to
clear the cache.
* feat: add about tab to EOSdash
The about tab resembles the former hello tab and provides extra information.
* feat: Adapt changelog and prepare for release management
Release management using commitizen is added. The changelog file is adapted and
teh changelog and a description for release management is added in the
documentation.
* feat(doc): Improve install and devlopment documentation
Provide a more concise installation description in Readme.md and add extra
installation page and development page to documentation.
* chore: Use memory cache for interpolation instead of dict in inverter
Decorate calculate_self_consumption() with @cachemethod_until_update to cache
results in memory during an energy management/ optimization run. Replacement
of dict type caching in inverter is now possible because all optimization
runs are properly locked and the memory cache CacheUntilUpdateStore is properly
cleared at the start of any energy management/ optimization operation.
* chore: refactor genetic
Refactor the genetic algorithm modules for enhanced module structure and better
readability. Removed unnecessary and overcomplex devices singleton. Also
split devices configuration from genetic algorithm parameters to allow further
development independently from genetic algorithm parameter format. Move
charge rates configuration for electric vehicles from optimization to devices
configuration to allow to have different charge rates for different cars in
the future.
* chore: Rename memory cache to CacheEnergyManagementStore
The name better resembles the task of the cache to chache function and method
results for an energy management run. Also the decorator functions are renamed
accordingly: cachemethod_energy_management, cache_energy_management
* chore: use class properties for config/ems/prediction mixin classes
* chore: skip debug logs from mathplotlib
Mathplotlib is very noisy in debug mode.
* chore: automatically sync bokeh js to bokeh python package
bokeh was updated to 3.8.0, make JS CDN automatically follow the package version.
* chore: rename hello.py to about.py
Make hello.py the adapted EOSdash about page.
* chore: remove demo page from EOSdash
As no the plan and prediction pages are working without configuration, the demo
page is no longer necessary
* chore: split test_server.py for system test
Split test_server.py to create explicit test_system.py for system tests.
* chore: move doc utils to generate_config_md.py
The doc utils are only used in scripts/generate_config_md.py. Move it there to
attribute for strong cohesion.
* chore: improve pydantic merge model documentation
* chore: remove pendulum warning from readme
* chore: remove GitHub discussions from contributing documentation
Github discussions is to be replaced by Akkudoktor.net.
* chore(release): bump version to 0.1.0+dev for development
* build(deps): bump fastapi[standard] from 0.115.14 to 0.117.1
bump fastapi and make coverage version (for pytest-cov) explicit to avoid pip break.
* build(deps): bump uvicorn from 0.36.0 to 0.37.0
BREAKING CHANGE: EOS configuration changed. V1 API changed.
- The available_charge_rates_percent configuration is removed from optimization.
Use the new charge_rate configuration for the electric vehicle
- Optimization configuration parameter hours renamed to horizon_hours
- Device configuration now has to provide the number of devices and device
properties per device.
- Specific prediction provider configuration to be provided by explicit
configuration item (no union for all providers).
- Measurement keys to be provided as a list.
- New feed in tariff providers have to be configured.
- /v1/measurement/loadxxx endpoints are removed. Use generic mesaurement endpoints.
- /v1/admin/cache/clear now clears all cache files. Use
/v1/admin/cache/clear-expired to only clear all expired cache files.
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
1933 lines
69 KiB
Python
1933 lines
69 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,
|
||
TimeWindow,
|
||
TimeWindowSequence,
|
||
_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
|
||
|
||
|
||
# -----------------------------
|
||
# TimeWindow
|
||
# -----------------------------
|
||
|
||
|
||
class TestTimeWindow:
|
||
"""Tests for the TimeWindow model."""
|
||
|
||
def test_datetime_within_and_outside_window(self):
|
||
"""Test datetime containment logic inside and outside the time window."""
|
||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=3))
|
||
assert window.contains(DateTime(2025, 7, 12, 7, 30)) is True # Inside
|
||
assert window.contains(DateTime(2025, 7, 12, 9, 30)) is False # Outside
|
||
|
||
def test_contains_with_duration(self):
|
||
"""Test datetime with duration that does and doesn't fit in the window."""
|
||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=3))
|
||
assert window.contains(DateTime(2025, 7, 12, 6, 30), duration=Duration(minutes=60)) is True
|
||
assert window.contains(DateTime(2025, 7, 12, 6, 30), duration=Duration(hours=3)) is False
|
||
|
||
def test_day_of_week_filter(self):
|
||
"""Test time window restricted by day of week."""
|
||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), day_of_week=5) # Saturday
|
||
assert window.contains(DateTime(2025, 7, 12, 6, 30)) is True # Saturday
|
||
assert window.contains(DateTime(2025, 7, 11, 6, 30)) is False # Friday
|
||
|
||
def test_day_of_week_as_english_name(self):
|
||
"""Test time window with English weekday name."""
|
||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), day_of_week="monday")
|
||
assert window.contains(DateTime(2025, 7, 7, 6, 30)) is True # Monday
|
||
assert window.contains(DateTime(2025, 7, 5, 6, 30)) is False # Saturday
|
||
|
||
def test_specific_date_filter(self):
|
||
"""Test time window restricted by exact date."""
|
||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), date=Date(2025, 7, 12))
|
||
assert window.contains(DateTime(2025, 7, 12, 6, 30)) is True
|
||
assert window.contains(DateTime(2025, 7, 13, 6, 30)) is False
|
||
|
||
def test_invalid_field_types_raise_validation(self):
|
||
"""Test invalid types raise a Pydantic validation error."""
|
||
with pytest.raises(ValidationError):
|
||
TimeWindow(start_time="not_a_time", duration="3h")
|
||
|
||
@pytest.mark.parametrize("locale, weekday_name, expected_dow", [
|
||
("de", "Montag", 0),
|
||
("de", "Samstag", 5),
|
||
("es", "lunes", 0),
|
||
("es", "sábado", 5),
|
||
("fr", "lundi", 0),
|
||
("fr", "samedi", 5),
|
||
])
|
||
def test_localized_day_names(self, locale, weekday_name, expected_dow):
|
||
"""Test that localized weekday names are resolved to correct weekday index."""
|
||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), day_of_week=weekday_name, locale=locale)
|
||
assert window.day_of_week == expected_dow
|
||
|
||
|
||
# ------------------
|
||
# TimeWindowSequence
|
||
# ------------------
|
||
|
||
|
||
class TestTimeWindowSequence:
|
||
"""Test suite for TimeWindowSequence model."""
|
||
|
||
@pytest.fixture
|
||
def sample_time_window_1(self):
|
||
"""Morning window: 9:00 AM - 12:00 PM."""
|
||
return TimeWindow(
|
||
start_time=Time(9, 0, 0),
|
||
duration=Duration(hours=3)
|
||
)
|
||
|
||
@pytest.fixture
|
||
def sample_time_window_2(self):
|
||
"""Afternoon window: 2:00 PM - 5:00 PM."""
|
||
return TimeWindow(
|
||
start_time=Time(14, 0, 0),
|
||
duration=Duration(hours=3)
|
||
)
|
||
|
||
@pytest.fixture
|
||
def monday_window(self):
|
||
"""Monday only window: 10:00 AM - 11:00 AM."""
|
||
return TimeWindow(
|
||
start_time=Time(10, 0, 0),
|
||
duration=Duration(hours=1),
|
||
day_of_week=0 # Monday
|
||
)
|
||
|
||
@pytest.fixture
|
||
def specific_date_window(self):
|
||
"""Specific date window: 1:00 PM - 3:00 PM on 2025-01-15."""
|
||
return TimeWindow(
|
||
start_time=Time(13, 0, 0),
|
||
duration=Duration(hours=2),
|
||
date=Date(2025, 1, 15)
|
||
)
|
||
|
||
@pytest.fixture
|
||
def sample_sequence(self, sample_time_window_1, sample_time_window_2):
|
||
"""Sequence with morning and afternoon windows."""
|
||
return TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2])
|
||
|
||
@pytest.fixture
|
||
def sample_sequence_json(self, sample_time_window_1, sample_time_window_2):
|
||
"""Sequence with morning and afternoon windows."""
|
||
seq_json = TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2]).model_dump()
|
||
return seq_json
|
||
|
||
@pytest.fixture
|
||
def sample_sequence_json_str(self, sample_time_window_1, sample_time_window_2):
|
||
"""Sequence with morning and afternoon windows."""
|
||
seq_json_str = TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2]).model_dumps(indent=2)
|
||
return seq_json_str
|
||
|
||
@pytest.fixture
|
||
def reference_date(self):
|
||
"""Reference date for testing: 2025-01-15 (Wednesday)."""
|
||
return pendulum.parse("2025-01-15T08:00:00")
|
||
|
||
def test_init_with_none_windows(self):
|
||
"""Test initialization with None windows creates empty list."""
|
||
sequence = TimeWindowSequence()
|
||
assert sequence.windows == []
|
||
assert len(sequence) == 0
|
||
|
||
def test_init_with_explicit_none(self):
|
||
"""Test initialization with explicit None windows."""
|
||
sequence = TimeWindowSequence(windows=None)
|
||
assert sequence.windows == []
|
||
assert len(sequence) == 0
|
||
|
||
def test_init_with_empty_list(self):
|
||
"""Test initialization with empty list."""
|
||
sequence = TimeWindowSequence(windows=[])
|
||
assert sequence.windows == []
|
||
assert len(sequence) == 0
|
||
|
||
def test_init_with_windows(self, sample_time_window_1, sample_time_window_2):
|
||
"""Test initialization with windows."""
|
||
sequence = TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2])
|
||
assert len(sequence) == 2
|
||
assert sequence.windows is not None # make mypy happy
|
||
assert sequence.windows[0] == sample_time_window_1
|
||
assert sequence.windows[1] == sample_time_window_2
|
||
|
||
def test_iterator_protocol(self, sample_sequence):
|
||
"""Test that sequence supports iteration."""
|
||
windows = list(sample_sequence)
|
||
assert len(windows) == 2
|
||
assert all(isinstance(window, TimeWindow) for window in windows)
|
||
|
||
def test_indexing(self, sample_sequence, sample_time_window_1):
|
||
"""Test indexing into sequence."""
|
||
assert sample_sequence[0] == sample_time_window_1
|
||
|
||
def test_length(self, sample_sequence):
|
||
"""Test len() support."""
|
||
assert len(sample_sequence) == 2
|
||
|
||
def test_contains_empty_sequence(self, reference_date):
|
||
"""Test contains() with empty sequence returns False."""
|
||
sequence = TimeWindowSequence()
|
||
assert not sequence.contains(reference_date)
|
||
assert not sequence.contains(reference_date, Duration(hours=1))
|
||
|
||
def test_contains_datetime_in_window(self, sample_sequence, reference_date):
|
||
"""Test contains() finds datetime in one of the windows."""
|
||
# 10:00 AM should be in the morning window (9:00 AM - 12:00 PM)
|
||
test_time = reference_date.replace(hour=10, minute=0)
|
||
assert sample_sequence.contains(test_time)
|
||
|
||
def test_contains_datetime_not_in_any_window(self, sample_sequence, reference_date):
|
||
"""Test contains() returns False when datetime is not in any window."""
|
||
# 1:00 PM should not be in any window (gap between morning and afternoon)
|
||
test_time = reference_date.replace(hour=13, minute=0)
|
||
assert not sample_sequence.contains(test_time)
|
||
|
||
def test_contains_with_duration_fits(self, sample_sequence, reference_date):
|
||
"""Test contains() with duration that fits in a window."""
|
||
# 10:00 AM with 1 hour duration should fit in morning window
|
||
test_time = reference_date.replace(hour=10, minute=0)
|
||
assert sample_sequence.contains(test_time, Duration(hours=1))
|
||
|
||
def test_contains_with_duration_too_long(self, sample_sequence, reference_date):
|
||
"""Test contains() with duration that doesn't fit in any window."""
|
||
# 11:00 AM with 2 hours duration won't fit in remaining morning window time
|
||
test_time = reference_date.replace(hour=11, minute=0)
|
||
assert not sample_sequence.contains(test_time, Duration(hours=2))
|
||
|
||
def test_earliest_start_time_empty_sequence(self, reference_date):
|
||
"""Test earliest_start_time() with empty sequence returns None."""
|
||
sequence = TimeWindowSequence()
|
||
assert sequence.earliest_start_time(Duration(hours=1), reference_date) is None
|
||
|
||
def test_earliest_start_time_finds_earliest(self, sample_sequence, reference_date):
|
||
"""Test earliest_start_time() finds the earliest time across all windows."""
|
||
# Should return 9:00 AM (start of morning window)
|
||
earliest = sample_sequence.earliest_start_time(Duration(hours=1), reference_date)
|
||
expected = reference_date.replace(hour=9, minute=0, second=0, microsecond=0)
|
||
assert earliest == expected
|
||
|
||
def test_earliest_start_time_duration_too_long(self, sample_sequence, reference_date):
|
||
"""Test earliest_start_time() with duration longer than any window."""
|
||
# 4 hours won't fit in any 3-hour window
|
||
assert sample_sequence.earliest_start_time(Duration(hours=4), reference_date) is None
|
||
|
||
def test_latest_start_time_empty_sequence(self, reference_date):
|
||
"""Test latest_start_time() with empty sequence returns None."""
|
||
sequence = TimeWindowSequence()
|
||
assert sequence.latest_start_time(Duration(hours=1), reference_date) is None
|
||
|
||
def test_latest_start_time_finds_latest(self, sample_sequence, reference_date):
|
||
"""Test latest_start_time() finds the latest time across all windows."""
|
||
# Should return 4:00 PM (latest start for 1 hour in afternoon window)
|
||
latest = sample_sequence.latest_start_time(Duration(hours=1), reference_date)
|
||
expected = reference_date.replace(hour=16, minute=0, second=0, microsecond=0)
|
||
assert latest == expected
|
||
|
||
def test_can_fit_duration_empty_sequence(self, reference_date):
|
||
"""Test can_fit_duration() with empty sequence returns False."""
|
||
sequence = TimeWindowSequence()
|
||
assert not sequence.can_fit_duration(Duration(hours=1), reference_date)
|
||
|
||
def test_can_fit_duration_fits_in_one_window(self, sample_sequence, reference_date):
|
||
"""Test can_fit_duration() returns True when duration fits in one window."""
|
||
assert sample_sequence.can_fit_duration(Duration(hours=2), reference_date)
|
||
|
||
def test_can_fit_duration_too_long(self, sample_sequence, reference_date):
|
||
"""Test can_fit_duration() returns False when duration is too long."""
|
||
assert not sample_sequence.can_fit_duration(Duration(hours=4), reference_date)
|
||
|
||
def test_available_duration_empty_sequence(self, reference_date):
|
||
"""Test available_duration() with empty sequence returns None."""
|
||
sequence = TimeWindowSequence()
|
||
assert sequence.available_duration(reference_date) is None
|
||
|
||
def test_available_duration_sums_all_windows(self, sample_sequence, reference_date):
|
||
"""Test available_duration() sums durations from all applicable windows."""
|
||
# 3 hours + 3 hours = 6 hours total
|
||
total = sample_sequence.available_duration(reference_date)
|
||
assert total == Duration(hours=6)
|
||
|
||
def test_available_duration_with_day_restriction(self, monday_window, reference_date):
|
||
"""Test available_duration() respects day restrictions."""
|
||
sequence = TimeWindowSequence(windows=[monday_window])
|
||
|
||
# Reference date is Wednesday, so Monday window shouldn't apply
|
||
assert sequence.available_duration(reference_date) is None
|
||
|
||
# Monday date should apply
|
||
monday_date = pendulum.parse("2025-01-13T08:00:00") # Monday
|
||
assert sequence.available_duration(monday_date) == Duration(hours=1)
|
||
|
||
def test_get_applicable_windows_empty_sequence(self, reference_date):
|
||
"""Test get_applicable_windows() with empty sequence."""
|
||
sequence = TimeWindowSequence()
|
||
assert sequence.get_applicable_windows(reference_date) == []
|
||
|
||
def test_get_applicable_windows_all_apply(self, sample_sequence, reference_date):
|
||
"""Test get_applicable_windows() returns all windows when they all apply."""
|
||
applicable = sample_sequence.get_applicable_windows(reference_date)
|
||
assert len(applicable) == 2
|
||
|
||
def test_get_applicable_windows_with_restrictions(self, monday_window, reference_date):
|
||
"""Test get_applicable_windows() respects day restrictions."""
|
||
sequence = TimeWindowSequence(windows=[monday_window])
|
||
|
||
# Wednesday - no applicable windows
|
||
assert sequence.get_applicable_windows(reference_date) == []
|
||
|
||
# Monday - one applicable window
|
||
monday_date = pendulum.parse("2025-01-13T08:00:00")
|
||
applicable = sequence.get_applicable_windows(monday_date)
|
||
assert len(applicable) == 1
|
||
assert applicable[0] == monday_window
|
||
|
||
def test_find_windows_for_duration_empty_sequence(self, reference_date):
|
||
"""Test find_windows_for_duration() with empty sequence."""
|
||
sequence = TimeWindowSequence()
|
||
assert sequence.find_windows_for_duration(Duration(hours=1), reference_date) == []
|
||
|
||
def test_find_windows_for_duration_all_fit(self, sample_sequence, reference_date):
|
||
"""Test find_windows_for_duration() when duration fits in all windows."""
|
||
fitting = sample_sequence.find_windows_for_duration(Duration(hours=2), reference_date)
|
||
assert len(fitting) == 2
|
||
|
||
def test_find_windows_for_duration_some_fit(self, sample_sequence, reference_date):
|
||
"""Test find_windows_for_duration() when duration fits in some windows."""
|
||
# Add a short window that can't fit 2.5 hours
|
||
short_window = TimeWindow(start_time=Time(18, 0, 0), duration=Duration(hours=1))
|
||
sequence = TimeWindowSequence(windows=sample_sequence.windows + [short_window])
|
||
|
||
fitting = sequence.find_windows_for_duration(Duration(hours=2, minutes=30), reference_date)
|
||
assert len(fitting) == 2 # Only the first two windows can fit 2.5 hours
|
||
|
||
def test_get_all_possible_start_times_empty_sequence(self, reference_date):
|
||
"""Test get_all_possible_start_times() with empty sequence."""
|
||
sequence = TimeWindowSequence()
|
||
assert sequence.get_all_possible_start_times(Duration(hours=1), reference_date) == []
|
||
|
||
def test_get_all_possible_start_times_multiple_windows(self, sample_sequence, reference_date):
|
||
"""Test get_all_possible_start_times() returns ranges for all fitting windows."""
|
||
ranges = sample_sequence.get_all_possible_start_times(Duration(hours=1), reference_date)
|
||
assert len(ranges) == 2
|
||
|
||
# Check morning window range
|
||
earliest_morning, latest_morning, morning_window = ranges[0]
|
||
assert earliest_morning == reference_date.replace(hour=9, minute=0, second=0, microsecond=0)
|
||
assert latest_morning == reference_date.replace(hour=11, minute=0, second=0, microsecond=0)
|
||
|
||
# Check afternoon window range
|
||
earliest_afternoon, latest_afternoon, afternoon_window = ranges[1]
|
||
assert earliest_afternoon == reference_date.replace(hour=14, minute=0, second=0, microsecond=0)
|
||
assert latest_afternoon == reference_date.replace(hour=16, minute=0, second=0, microsecond=0)
|
||
|
||
def test_add_window(self, sample_time_window_1):
|
||
"""Test adding a window to the sequence."""
|
||
sequence = TimeWindowSequence()
|
||
assert len(sequence) == 0
|
||
|
||
sequence.add_window(sample_time_window_1)
|
||
assert len(sequence) == 1
|
||
assert sequence[0] == sample_time_window_1
|
||
|
||
def test_remove_window(self, sample_sequence, sample_time_window_1):
|
||
"""Test removing a window from the sequence."""
|
||
assert len(sample_sequence) == 2
|
||
|
||
removed = sample_sequence.remove_window(0)
|
||
assert removed == sample_time_window_1
|
||
assert len(sample_sequence) == 1
|
||
|
||
def test_remove_window_invalid_index(self, sample_sequence):
|
||
"""Test removing a window with invalid index raises IndexError."""
|
||
with pytest.raises(IndexError):
|
||
sample_sequence.remove_window(10)
|
||
|
||
def test_remove_window_from_empty_sequence(self):
|
||
"""Test removing a window from empty sequence raises IndexError."""
|
||
sequence = TimeWindowSequence()
|
||
with pytest.raises(IndexError):
|
||
sequence.remove_window(0)
|
||
|
||
def test_clear_windows(self, sample_sequence):
|
||
"""Test clearing all windows from the sequence."""
|
||
assert len(sample_sequence) == 2
|
||
|
||
sample_sequence.clear_windows()
|
||
assert len(sample_sequence) == 0
|
||
assert sample_sequence.windows == []
|
||
|
||
def test_sort_windows_by_start_time(self, reference_date):
|
||
"""Test sorting windows by start time."""
|
||
# Create windows in reverse chronological order
|
||
afternoon_window = TimeWindow(start_time=Time(14, 0, 0), duration=Duration(hours=2))
|
||
morning_window = TimeWindow(start_time=Time(9, 0, 0), duration=Duration(hours=2))
|
||
evening_window = TimeWindow(start_time=Time(18, 0, 0), duration=Duration(hours=2))
|
||
|
||
sequence = TimeWindowSequence(windows=[afternoon_window, morning_window, evening_window])
|
||
sequence.sort_windows_by_start_time(reference_date)
|
||
|
||
# Should now be sorted: morning, afternoon, evening
|
||
assert sequence[0] == morning_window
|
||
assert sequence[1] == afternoon_window
|
||
assert sequence[2] == evening_window
|
||
|
||
def test_sort_windows_with_non_applicable_windows(self, monday_window, reference_date):
|
||
"""Test sorting windows with some non-applicable windows."""
|
||
daily_window = TimeWindow(start_time=Time(10, 0, 0), duration=Duration(hours=1))
|
||
|
||
sequence = TimeWindowSequence(windows=[monday_window, daily_window])
|
||
sequence.sort_windows_by_start_time(reference_date) # Wednesday
|
||
|
||
# Daily window should come first (applicable), Monday window last (not applicable)
|
||
assert sequence[0] == daily_window
|
||
assert sequence[1] == monday_window
|
||
|
||
def test_sort_windows_empty_sequence(self, reference_date):
|
||
"""Test sorting an empty sequence doesn't raise errors."""
|
||
sequence = TimeWindowSequence()
|
||
sequence.sort_windows_by_start_time(reference_date)
|
||
assert len(sequence) == 0
|
||
|
||
def test_default_reference_date_handling(self, sample_sequence):
|
||
"""Test that methods handle default reference date (today) correctly."""
|
||
# These should not raise errors and should return reasonable values
|
||
assert isinstance(sample_sequence.can_fit_duration(Duration(hours=1)), bool)
|
||
assert sample_sequence.available_duration() is not None
|
||
assert isinstance(sample_sequence.get_applicable_windows(), list)
|
||
|
||
def test_specific_date_window_functionality(self, specific_date_window):
|
||
"""Test functionality with specific date restrictions."""
|
||
sequence = TimeWindowSequence(windows=[specific_date_window])
|
||
|
||
# Should work on the specific date
|
||
specific_date = pendulum.parse("2025-01-15T12:00:00")
|
||
assert sequence.can_fit_duration(Duration(hours=1), specific_date)
|
||
|
||
# Should not work on other dates
|
||
other_date = pendulum.parse("2025-01-16T12:00:00")
|
||
assert not sequence.can_fit_duration(Duration(hours=1), other_date)
|
||
|
||
def test_edge_cases_with_zero_duration(self, sample_sequence, reference_date):
|
||
"""Test edge cases with zero duration."""
|
||
zero_duration = Duration()
|
||
|
||
# Should be able to fit zero duration
|
||
assert sample_sequence.can_fit_duration(zero_duration, reference_date)
|
||
|
||
# Should find start times for zero duration
|
||
earliest = sample_sequence.earliest_start_time(zero_duration, reference_date)
|
||
assert earliest is not None
|
||
|
||
def test_overlapping_windows(self, reference_date):
|
||
"""Test behavior with overlapping windows."""
|
||
window1 = TimeWindow(start_time=Time(10, 0, 0), duration=Duration(hours=3))
|
||
window2 = TimeWindow(start_time=Time(11, 0, 0), duration=Duration(hours=3))
|
||
|
||
sequence = TimeWindowSequence(windows=[window1, window2])
|
||
|
||
# Should handle overlapping windows correctly
|
||
test_time = reference_date.replace(hour=11, minute=30)
|
||
assert sequence.contains(test_time)
|
||
|
||
# Total duration should be sum of both windows (even though they overlap)
|
||
total = sequence.available_duration(reference_date)
|
||
assert total == Duration(hours=6)
|
||
|
||
def test_sequence_model_dump(self, sample_sequence_json):
|
||
"""Test that model dump creates the correct json."""
|
||
assert sample_sequence_json == json.loads("""
|
||
{
|
||
"windows": [
|
||
{
|
||
"start_time": "09:00:00.000000",
|
||
"duration": "3 hours",
|
||
"day_of_week": null,
|
||
"date": null,
|
||
"locale": null
|
||
},
|
||
{
|
||
"start_time": "14:00:00.000000",
|
||
"duration": "3 hours",
|
||
"day_of_week": null,
|
||
"date": null,
|
||
"locale": null
|
||
}
|
||
]
|
||
}""")
|
||
|
||
|
||
# -----------------------------
|
||
# 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",
|
||
"Atlantic/Canary",
|
||
"02/02/24",
|
||
None,
|
||
"UTC",
|
||
None,
|
||
False,
|
||
pendulum.datetime(2024, 2, 2, 0, 0, 0, tz="UTC"),
|
||
False,
|
||
),
|
||
(
|
||
"TC015",
|
||
"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
|
||
# ---------------------------------------
|
||
(
|
||
"TC016",
|
||
"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,
|
||
),
|
||
(
|
||
"TC017",
|
||
"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,
|
||
),
|
||
(
|
||
"TC018",
|
||
"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,
|
||
),
|
||
(
|
||
"TC019",
|
||
"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
|
||
(
|
||
"TC020",
|
||
"Etc/UTC",
|
||
"2023-11-06T00:00:00",
|
||
"UTC",
|
||
None,
|
||
None,
|
||
None,
|
||
"2023-11-06T00:00:00Z",
|
||
False,
|
||
),
|
||
# local timezone "Europe/Berlin"
|
||
(
|
||
"TC021",
|
||
"Europe/Berlin",
|
||
"2023-11-06T00:00:00",
|
||
"UTC",
|
||
"Europe/Berlin",
|
||
None,
|
||
None,
|
||
"2023-11-05T23:00:00Z",
|
||
False,
|
||
),
|
||
# - no microseconds
|
||
(
|
||
"TC022",
|
||
"Atlantic/Canary",
|
||
"2024-10-30T00:00:00+01:00",
|
||
"UTC",
|
||
None,
|
||
None,
|
||
None,
|
||
"2024-10-29T23:00:00Z",
|
||
False,
|
||
),
|
||
(
|
||
"TC023",
|
||
"Atlantic/Canary",
|
||
"2024-10-30T01:00:00+01:00",
|
||
"utc",
|
||
None,
|
||
None,
|
||
None,
|
||
"2024-10-30T00:00:00Z",
|
||
False,
|
||
),
|
||
# - with microseconds
|
||
(
|
||
"TC024",
|
||
"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
|
||
(
|
||
"TC025",
|
||
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:
|
||
assert compare.time_diff < 300
|
||
else:
|
||
assert compare.equal == True
|
||
|
||
|
||
# -----------------------------
|
||
# to_duration
|
||
# -----------------------------
|
||
|
||
|
||
# Test cases for valid duration inputs
|
||
@pytest.mark.parametrize(
|
||
"input_value, expected_output",
|
||
[
|
||
# duration input
|
||
(pendulum.duration(days=1), pendulum.duration(days=1)),
|
||
# String input
|
||
("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 input
|
||
(3600, pendulum.duration(seconds=3600)), # 1 hour
|
||
(86400, pendulum.duration(days=1)), # 1 day
|
||
(1800.5, pendulum.duration(seconds=1800.5)), # 30 minutes and 0.5 seconds
|
||
# Tuple/List input
|
||
((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(input_value, expected_output):
|
||
"""Test to_duration with valid inputs."""
|
||
assert to_duration(input_value) == expected_output
|
||
|
||
|
||
def test_to_duration_summation():
|
||
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")
|
||
|
||
|
||
# -----------------------------
|
||
# 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
|
||
|
||
|
||
def test_to_duration_excessive_length_raises_valueerror():
|
||
"""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
|