- direct list construction validates entries before set() coercion
(bool collapse and unhashable items both raise RuleValidationError)
- unknown-key error sorts by repr (mixed-type keys never leak TypeError)
- string rule_type accepted ONLY as exact model names: storage aliases
('monthly'/'custom'), whitespace padding, and case variants rejected;
the DAO owns storage-string mapping
- purity guard checks ImportFrom alias names so 'from . import db' is
caught, not just node.module
- _is_plain_int requires type(value) is int: unhashable int subclasses
cannot construct
466 lines
17 KiB
Python
466 lines
17 KiB
Python
"""Tests for recurrence.py: the ScheduleRule model and serialization."""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
import pytest
|
|
|
|
from custom_components.nestquest.recurrence import (
|
|
RuleType,
|
|
RuleValidationError,
|
|
ScheduleRule,
|
|
)
|
|
|
|
|
|
def _rule(**kwargs) -> ScheduleRule:
|
|
return ScheduleRule(**kwargs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Valid construction for every rule type
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_daily_rule_constructs_with_defaults() -> None:
|
|
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
|
assert rule.rule_type is RuleType.DAILY
|
|
assert rule.interval == 1
|
|
assert rule.weekday_set is None
|
|
assert rule.day_of_month is None
|
|
assert rule.nth_weekday is None
|
|
assert rule.month is None
|
|
assert rule.end_date is None
|
|
|
|
|
|
def test_string_rule_type_coerces() -> None:
|
|
rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01")
|
|
assert rule.rule_type is RuleType.WEEKLY
|
|
|
|
|
|
def test_weekly_rule_requires_and_freezes_weekday_set() -> None:
|
|
rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, start_date="2026-09-01")
|
|
assert rule.weekday_set == frozenset({0, 2, 4})
|
|
# A mutable set passed in is frozen on construction.
|
|
source = {1, 3}
|
|
rule2 = _rule(rule_type=RuleType.WEEKLY, weekday_set=source, start_date="2026-09-01")
|
|
source.add(5)
|
|
assert rule2.weekday_set == frozenset({1, 3})
|
|
|
|
|
|
def test_monthly_day_rule_fields() -> None:
|
|
rule = _rule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15, start_date="2026-09-01")
|
|
assert rule.day_of_month == 15
|
|
assert rule.nth_weekday is None
|
|
|
|
|
|
def test_monthly_weekday_rule_fields() -> None:
|
|
rule = _rule(
|
|
rule_type=RuleType.MONTHLY_WEEKDAY,
|
|
nth_weekday=2,
|
|
nth_weekday_weekday=1,
|
|
start_date="2026-09-01",
|
|
)
|
|
assert rule.nth_weekday == 2
|
|
assert rule.nth_weekday_weekday == 1
|
|
|
|
|
|
def test_monthly_weekday_last_friday_allowed() -> None:
|
|
rule = _rule(
|
|
rule_type=RuleType.MONTHLY_WEEKDAY,
|
|
nth_weekday=-1,
|
|
nth_weekday_weekday=4,
|
|
start_date="2026-09-01",
|
|
)
|
|
assert rule.nth_weekday == -1
|
|
|
|
|
|
def test_yearly_rule_fields() -> None:
|
|
rule = _rule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01")
|
|
assert rule.month == 6
|
|
|
|
|
|
def test_custom_days_rule_fields() -> None:
|
|
rule = _rule(
|
|
rule_type=RuleType.CUSTOM_DAYS,
|
|
weekday_set={1, 3, 5},
|
|
interval=2,
|
|
start_date="2026-09-01",
|
|
)
|
|
assert rule.weekday_set == frozenset({1, 3, 5})
|
|
assert rule.interval == 2
|
|
|
|
|
|
def test_end_date_round_trips(tmp_path) -> None:
|
|
rule = _rule(
|
|
rule_type=RuleType.DAILY,
|
|
start_date="2026-09-01",
|
|
end_date="2026-12-31",
|
|
)
|
|
assert rule.end_date == "2026-12-31"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Invalid combinations are rejected at construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kwargs",
|
|
[
|
|
# weekly without weekday_set
|
|
{"rule_type": RuleType.WEEKLY.value, "start_date": "2026-09-01"},
|
|
# daily carrying a weekday_set
|
|
{"rule_type": "daily", "weekday_set": {0}, "start_date": "2026-09-01"},
|
|
# monthly_day without day_of_month
|
|
{"rule_type": "monthly_day", "start_date": "2026-09-01"},
|
|
# monthly_day out of range
|
|
{"rule_type": "monthly_day", "day_of_month": 32, "start_date": "2026-09-01"},
|
|
# monthly_day carrying weekday_set
|
|
{"rule_type": "monthly_day", "day_of_month": 5, "weekday_set": {0},
|
|
"start_date": "2026-09-01"},
|
|
# yearly without month
|
|
{"rule_type": "yearly", "start_date": "2026-09-01"},
|
|
# yearly out of range
|
|
{"rule_type": "yearly", "month": 13, "start_date": "2026-09-01"},
|
|
# unknown type
|
|
{"rule_type": "bogus", "start_date": "2026-09-01"},
|
|
# interval zero
|
|
{"rule_type": "daily", "interval": 0, "start_date": "2026-09-01"},
|
|
# interval non-integer
|
|
{"rule_type": "daily", "interval": "2", "start_date": "2026-09-01"},
|
|
# end before start
|
|
{"rule_type": "daily", "start_date": "2026-09-01",
|
|
"end_date": "2026-08-31"},
|
|
# malformed dates
|
|
{"rule_type": "daily", "start_date": "not-a-date"},
|
|
{"rule_type": "daily", "start_date": "2026-9-1"},
|
|
# weekly weekday out of range
|
|
{"rule_type": RuleType.WEEKLY.value, "weekday_set": {7}, "start_date": "2026-09-01"},
|
|
# weekly empty set
|
|
{"rule_type": RuleType.WEEKLY.value, "weekday_set": set(), "start_date": "2026-09-01"},
|
|
# custom_days carrying month-specific fields
|
|
{"rule_type": "custom_days", "weekday_set": {0}, "day_of_month": 5,
|
|
"start_date": "2026-09-01"},
|
|
{"rule_type": "custom_days", "weekday_set": {0}, "nth_weekday": 1,
|
|
"nth_weekday_weekday": 0, "start_date": "2026-09-01"},
|
|
{"rule_type": "custom_days", "weekday_set": {0}, "month": 3,
|
|
"start_date": "2026-09-01"},
|
|
# monthly_weekday position invalid
|
|
{"rule_type": "monthly_weekday", "nth_weekday": 0,
|
|
"nth_weekday_weekday": 1, "start_date": "2026-09-01"},
|
|
{"rule_type": "monthly_weekday", "nth_weekday": 6,
|
|
"nth_weekday_weekday": 1, "start_date": "2026-09-01"},
|
|
{"rule_type": "monthly_weekday", "nth_weekday": 2,
|
|
"nth_weekday_weekday": None, "start_date": "2026-09-01"},
|
|
{"rule_type": "monthly_weekday", "nth_weekday": 2,
|
|
"nth_weekday_weekday": 7, "start_date": "2026-09-01"},
|
|
# monthly_day carrying nth fields
|
|
{"rule_type": "monthly_day", "day_of_month": 5, "nth_weekday": 1,
|
|
"nth_weekday_weekday": 0, "start_date": "2026-09-01"},
|
|
],
|
|
)
|
|
def test_invalid_combinations_raise(kwargs) -> None:
|
|
with pytest.raises(RuleValidationError):
|
|
_rule(**kwargs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Immutability
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_rule_is_immutable() -> None:
|
|
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
|
with pytest.raises(AttributeError):
|
|
rule.interval = 2
|
|
with pytest.raises(AttributeError):
|
|
del rule.interval
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Serialization round-trip for every rule type
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"rule",
|
|
[
|
|
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"),
|
|
ScheduleRule(rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01",
|
|
end_date="2026-12-31"),
|
|
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
|
|
start_date="2026-09-01"),
|
|
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={6}, interval=2,
|
|
start_date="2026-09-01"),
|
|
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
|
start_date="2026-09-01"),
|
|
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=3,
|
|
start_date="2026-09-01"),
|
|
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
|
nth_weekday_weekday=1, start_date="2026-09-01"),
|
|
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
|
|
nth_weekday_weekday=4, interval=2,
|
|
start_date="2026-09-01"),
|
|
ScheduleRule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01"),
|
|
ScheduleRule(rule_type=RuleType.YEARLY, month=2, day_of_month=None,
|
|
interval=2, start_date="2026-09-01"),
|
|
ScheduleRule(rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5},
|
|
interval=2, start_date="2026-09-01"),
|
|
],
|
|
)
|
|
def test_to_dict_from_dict_round_trips_losslessly(rule) -> None:
|
|
rebuilt = ScheduleRule.from_dict(rule.to_dict())
|
|
assert rebuilt == rule
|
|
# Round-tripping twice is stable.
|
|
assert ScheduleRule.from_dict(rebuilt.to_dict()) == rule
|
|
|
|
|
|
def test_from_dict_rejects_unknown_fields() -> None:
|
|
with pytest.raises(RuleValidationError, match="unknown rule fields"):
|
|
ScheduleRule.from_dict({"rule_type": "daily", "bogus": 1})
|
|
|
|
|
|
def test_from_dict_rejects_missing_rule_type() -> None:
|
|
with pytest.raises(RuleValidationError, match="missing required"):
|
|
ScheduleRule.from_dict({"interval": 1})
|
|
|
|
|
|
def test_from_dict_rejects_non_dict() -> None:
|
|
with pytest.raises(RuleValidationError):
|
|
ScheduleRule.from_dict("daily")
|
|
|
|
|
|
def test_from_dict_defaults_start_date_when_absent() -> None:
|
|
rule = ScheduleRule.from_dict({"rule_type": "daily"})
|
|
assert rule.start_date == "1970-01-01"
|
|
|
|
|
|
def test_from_dict_accepts_list_weekday_set() -> None:
|
|
rule = ScheduleRule.from_dict(
|
|
{"rule_type": RuleType.WEEKLY.value, "weekday_set": [0, 2, 4]}
|
|
)
|
|
assert rule.weekday_set == frozenset({0, 2, 4})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Equality and hashing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_equal_rules_compare_equal_and_hash_equal() -> None:
|
|
a = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01")
|
|
b = _rule(rule_type=RuleType.WEEKLY, weekday_set={2, 0}, start_date="2026-09-01")
|
|
assert a == b
|
|
assert hash(a) == hash(b)
|
|
|
|
|
|
def test_different_rules_compare_unequal() -> None:
|
|
a = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
|
b = _rule(rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01")
|
|
assert a != b
|
|
|
|
|
|
def test_rule_not_equal_to_other_types() -> None:
|
|
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
|
assert rule != "daily"
|
|
assert (rule == 42) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Purity guards: no HA imports, no database access
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_recurrence_module_imports_only_stdlib() -> None:
|
|
"""The model must carry no Home Assistant and no DB imports — checked
|
|
against the RESOLVED module path so relative forms (from .db import
|
|
x) are caught too."""
|
|
import ast
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
module_file = Path(
|
|
importlib.util.find_spec(
|
|
"custom_components.nestquest.recurrence"
|
|
).origin
|
|
)
|
|
tree = ast.parse(module_file.read_text())
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
assert "homeassistant" not in alias.name.lower(), (
|
|
f"recurrence.py must not import {alias.name}"
|
|
)
|
|
assert "sqlite" not in alias.name.lower(), (
|
|
f"recurrence.py must not import {alias.name}"
|
|
)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
module = (node.module or "").lower()
|
|
level = node.level # 1+ means relative import
|
|
# 'from . import db' has module=None and the name in the
|
|
# aliases: check BOTH the module and every imported name.
|
|
imported_names = [alias.name for alias in node.names]
|
|
forbidden_targets = {
|
|
"db", "schema", "dao_children", "dao_rules",
|
|
"dao_presence", "dao_instances", "migrations", "store",
|
|
}
|
|
if level:
|
|
for name in imported_names:
|
|
assert name not in forbidden_targets, (
|
|
f"recurrence.py must not import {name!r} from a "
|
|
"relative import"
|
|
)
|
|
if module:
|
|
assert module not in forbidden_targets, (
|
|
f"recurrence.py must not import from .{module}"
|
|
)
|
|
assert "db" not in module.split("."), (
|
|
f"recurrence.py must not import from {module}"
|
|
)
|
|
assert "homeassistant" not in module, (
|
|
f"recurrence.py must not import from {module}"
|
|
)
|
|
assert "sqlite" not in module, (
|
|
f"recurrence.py must not import from {module}"
|
|
)
|
|
|
|
|
|
def test_non_string_rule_type_rejected() -> None:
|
|
"""Only RuleType members and their string values construct."""
|
|
for bad in (123, True, None, 3.5, b"daily"):
|
|
with pytest.raises(RuleValidationError):
|
|
ScheduleRule(rule_type=bad, start_date="2026-09-01")
|
|
|
|
|
|
def test_weekly_rejects_month_and_nth_fields() -> None:
|
|
with pytest.raises(RuleValidationError, match="must not set"):
|
|
ScheduleRule(
|
|
rule_type="weekly",
|
|
weekday_set={0},
|
|
day_of_month=5,
|
|
start_date="2026-09-01",
|
|
)
|
|
with pytest.raises(RuleValidationError, match="must not set"):
|
|
ScheduleRule(
|
|
rule_type=RuleType.WEEKLY,
|
|
weekday_set={0},
|
|
month=3,
|
|
start_date="2026-09-01",
|
|
)
|
|
|
|
|
|
def test_daily_rejects_dangling_nth_weekday_weekday() -> None:
|
|
with pytest.raises(RuleValidationError, match="must not set"):
|
|
ScheduleRule(
|
|
rule_type=RuleType.DAILY,
|
|
nth_weekday_weekday=0,
|
|
start_date="2026-09-01",
|
|
)
|
|
|
|
|
|
def test_monthly_day_rejects_month() -> None:
|
|
with pytest.raises(RuleValidationError, match="must not set"):
|
|
ScheduleRule(
|
|
rule_type=RuleType.MONTHLY_DAY,
|
|
day_of_month=5,
|
|
month=3,
|
|
start_date="2026-09-01",
|
|
)
|
|
|
|
|
|
def test_yearly_rejects_day_and_nth_fields() -> None:
|
|
with pytest.raises(RuleValidationError, match="must not set"):
|
|
ScheduleRule(
|
|
rule_type=RuleType.YEARLY,
|
|
month=6,
|
|
day_of_month=15,
|
|
start_date="2026-09-01",
|
|
)
|
|
with pytest.raises(RuleValidationError, match="must not set"):
|
|
ScheduleRule(
|
|
rule_type=RuleType.YEARLY,
|
|
month=6,
|
|
nth_weekday=1,
|
|
nth_weekday_weekday=0,
|
|
start_date="2026-09-01",
|
|
)
|
|
|
|
|
|
def test_storage_value_mapping() -> None:
|
|
"""The enum exposes the schema's CHECK values via storage_value."""
|
|
assert RuleType.DAILY.storage_value == "daily"
|
|
assert RuleType.WEEKLY.storage_value == "weekly"
|
|
assert RuleType.MONTHLY_DAY.storage_value == "monthly"
|
|
assert RuleType.MONTHLY_WEEKDAY.storage_value == "monthly"
|
|
assert RuleType.YEARLY.storage_value == "yearly"
|
|
assert RuleType.CUSTOM_DAYS.storage_value == "custom"
|
|
|
|
|
|
def test_from_dict_bool_weekday_entry_rejected_before_set_coercion() -> None:
|
|
"""[0, False] must NOT collapse False into 0 via set() coercion."""
|
|
with pytest.raises(RuleValidationError, match="plain integers"):
|
|
ScheduleRule.from_dict(
|
|
{"rule_type": "weekly", "weekday_set": [0, False]}
|
|
)
|
|
|
|
|
|
def test_from_dict_unknown_key_types_never_leak_typeerror() -> None:
|
|
"""Mixed unhashable unknown keys must surface as
|
|
RuleValidationError, not a raw TypeError from sorting."""
|
|
data = {"rule_type": "daily", 42: "value"}
|
|
with pytest.raises(RuleValidationError):
|
|
ScheduleRule.from_dict(data)
|
|
|
|
|
|
def test_rule_type_is_a_dataclass() -> None:
|
|
import dataclasses
|
|
|
|
assert dataclasses.is_dataclass(ScheduleRule)
|
|
|
|
def test_direct_list_with_bool_weekday_rejected(tmp_path) -> None:
|
|
"""Direct construction with a list: entries validated pre-coercion."""
|
|
with pytest.raises(RuleValidationError, match="plain integers"):
|
|
ScheduleRule(
|
|
rule_type=RuleType.WEEKLY, weekday_set=[0, False],
|
|
start_date="2026-09-01",
|
|
)
|
|
|
|
|
|
def test_unhashable_list_item_leads_to_clear_error(tmp_path) -> None:
|
|
"""Unhashable entries raise RuleValidationError, not TypeError."""
|
|
with pytest.raises(RuleValidationError):
|
|
ScheduleRule(
|
|
rule_type=RuleType.WEEKLY, weekday_set=[[0]],
|
|
start_date="2026-09-01",
|
|
)
|
|
|
|
|
|
def test_from_dict_mixed_type_unknown_keys_raise_validation_error(
|
|
tmp_path,
|
|
) -> None:
|
|
"""sorted() over mixed key types must not leak TypeError."""
|
|
with pytest.raises(RuleValidationError, match="unknown rule fields"):
|
|
ScheduleRule.from_dict({"rule_type": "daily", 42: "value"})
|
|
|
|
|
|
def test_storage_aliases_and_whitespace_are_not_model_names(tmp_path) -> None:
|
|
"""The model boundary is exact names only: storage strings, padded
|
|
and case variants are all rejected."""
|
|
for bad in ("monthly", "custom", " DAILY ", "daily ", "DAILY"):
|
|
with pytest.raises(RuleValidationError):
|
|
ScheduleRule(rule_type=bad, start_date="2026-09-01")
|
|
|
|
|
|
def test_unhashable_int_subclass_interval_rejected() -> None:
|
|
"""An int subclass with __hash__ = None must not construct."""
|
|
|
|
class UnhashableInt(int):
|
|
__hash__ = None
|
|
|
|
with pytest.raises(RuleValidationError):
|
|
ScheduleRule(
|
|
rule_type=RuleType.DAILY,
|
|
interval=UnhashableInt(1),
|
|
start_date="2026-09-01",
|
|
)
|