c1aa2ebec5
Two HIGH issues surfaced by review of 3b8f00e:
1. ``_build_game_event`` was newly succeeding where the old store
raised ``ValueError("Invalid source type: game_event")``. The
coverage-assertion-symmetry comment was honest about it being
a path that didn't exist before, but silent broadening of the
create contract is a real behaviour delta — any internal caller
that previously caught the error would now succeed.
Make ``_build_game_event`` raise NotImplementedError. The
coverage assertion still passes (the entry exists), but the
historical "you can't create game_event sources through the
store" contract is preserved. game_event instances continue to
be wired up by the game-integration setup path.
2. The new ``create_source`` ran ``_check_name_unique`` BEFORE
``build_source``. When both ``source_type`` is invalid AND
``name`` collides with an existing source, the old code raised
``"Invalid source type: …"`` first; the new code raised the
name-collision error. Swap the order: build first (which
validates source_type), then check name uniqueness, then
persist. Bonus: a uuid is no longer minted for a source we end
up rejecting on type.
New test pins the game_event NotImplementedError so a future
refactor doesn't accidentally re-open the create path.
38 value-source-store + factory tests stay green; ruff clean.
272 lines
7.7 KiB
Python
272 lines
7.7 KiB
Python
"""Tests for the per-type ValueSource factory registry.
|
|
|
|
Locks in the contract that ``CREATE_BUILDERS`` and ``UPDATE_APPLIERS``
|
|
stay symmetric with storage's ``_VALUE_SOURCE_MAP``, and exercises a
|
|
couple of representative builders end-to-end.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from ledgrab.storage import value_source_factories as vsf
|
|
from ledgrab.storage.value_source import (
|
|
AdaptiveValueSource,
|
|
HAEntityValueSource,
|
|
StaticValueSource,
|
|
_VALUE_SOURCE_MAP,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Coverage / shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_create_builders_cover_every_storage_kind():
|
|
assert set(vsf.CREATE_BUILDERS.keys()) == set(_VALUE_SOURCE_MAP.keys())
|
|
|
|
|
|
def test_update_appliers_cover_every_storage_kind():
|
|
assert set(vsf.UPDATE_APPLIERS.keys()) == set(_VALUE_SOURCE_MAP.keys())
|
|
|
|
|
|
def test_coverage_assertion_raises_when_create_drifts(monkeypatch):
|
|
"""Removing a builder makes _assert_factory_coverage() fail loudly."""
|
|
pruned = {k: v for k, v in vsf.CREATE_BUILDERS.items() if k != "static"}
|
|
monkeypatch.setattr(vsf, "CREATE_BUILDERS", pruned)
|
|
with pytest.raises(RuntimeError, match="static"):
|
|
vsf._assert_factory_coverage()
|
|
|
|
|
|
def test_coverage_assertion_raises_when_update_drifts(monkeypatch):
|
|
pruned = {k: v for k, v in vsf.UPDATE_APPLIERS.items() if k != "http"}
|
|
monkeypatch.setattr(vsf, "UPDATE_APPLIERS", pruned)
|
|
with pytest.raises(RuntimeError, match="http"):
|
|
vsf._assert_factory_coverage()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_source — representative paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def test_build_source_static_uses_value_default():
|
|
src = vsf.build_source(
|
|
source_type="static",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
)
|
|
assert isinstance(src, StaticValueSource)
|
|
assert src.value == 1.0
|
|
assert src.source_type == "static"
|
|
|
|
|
|
def test_build_source_static_honours_supplied_value():
|
|
src = vsf.build_source(
|
|
source_type="static",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
value=0.42,
|
|
)
|
|
assert src.value == 0.42
|
|
|
|
|
|
def test_build_source_adaptive_time_requires_schedule_with_two_points():
|
|
with pytest.raises(ValueError, match="Time of day schedule"):
|
|
vsf.build_source(
|
|
source_type="adaptive_time",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
schedule=[{"time": "00:00", "value": 0.5}], # only one point
|
|
)
|
|
|
|
|
|
def test_build_source_adaptive_scene_routes_to_adaptive_class_with_scene_type():
|
|
"""``adaptive_scene`` and ``adaptive_time`` share AdaptiveValueSource but
|
|
differ in source_type — the builder must set the right discriminator."""
|
|
src = vsf.build_source(
|
|
source_type="adaptive_scene",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
picture_source_id="pic_42",
|
|
)
|
|
assert isinstance(src, AdaptiveValueSource)
|
|
assert src.source_type == "adaptive_scene"
|
|
assert src.picture_source_id == "pic_42"
|
|
|
|
|
|
def test_build_source_ha_entity_requires_ha_source_id_and_entity_id():
|
|
with pytest.raises(ValueError, match="HA source ID"):
|
|
vsf.build_source(
|
|
source_type="ha_entity",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
entity_id="light.foo", # missing ha_source_id
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Entity ID"):
|
|
vsf.build_source(
|
|
source_type="ha_entity",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
ha_source_id="ha_main", # missing entity_id
|
|
)
|
|
|
|
|
|
def test_build_source_http_validates_interval():
|
|
with pytest.raises(ValueError, match="interval_s"):
|
|
vsf.build_source(
|
|
source_type="http",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
http_endpoint_id="ep_1",
|
|
interval_s=0,
|
|
)
|
|
|
|
|
|
def test_build_source_unknown_type_raises():
|
|
with pytest.raises(ValueError, match="Invalid source type"):
|
|
vsf.build_source(
|
|
source_type="totally_unregistered",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
)
|
|
|
|
|
|
def test_build_source_game_event_is_not_creatable_via_store():
|
|
"""game_event sources were never creatable through the store API.
|
|
|
|
The builder exists for coverage-assertion symmetry but raises so the
|
|
historical contract holds (game-event value sources are wired up by
|
|
the game-integration setup path, not user CRUD).
|
|
"""
|
|
with pytest.raises(NotImplementedError, match="game_event"):
|
|
vsf.build_source(
|
|
source_type="game_event",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# apply_update — representative paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_apply_update_static_mutates_value():
|
|
src = vsf.build_source(
|
|
source_type="static",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
)
|
|
vsf.apply_update(src, value=0.75)
|
|
assert src.value == 0.75
|
|
|
|
|
|
def test_apply_update_ignores_unknown_kwargs():
|
|
"""Stray kwargs are silently dropped by each applier's ``**_``."""
|
|
src = vsf.build_source(
|
|
source_type="static",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
)
|
|
vsf.apply_update(src, value=0.5, totally_made_up_field="ignored")
|
|
assert src.value == 0.5
|
|
|
|
|
|
def test_apply_update_ha_entity_validates_smoothing_path():
|
|
src = vsf.build_source(
|
|
source_type="ha_entity",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
ha_source_id="ha_main",
|
|
entity_id="light.foo",
|
|
)
|
|
assert isinstance(src, HAEntityValueSource)
|
|
vsf.apply_update(src, smoothing=0.5, attribute="brightness")
|
|
assert src.smoothing == 0.5
|
|
assert src.attribute == "brightness"
|
|
|
|
|
|
def test_apply_update_http_rejects_zero_interval():
|
|
src = vsf.build_source(
|
|
source_type="http",
|
|
sid="vs_t",
|
|
name="Test",
|
|
now=_now(),
|
|
description=None,
|
|
tags=None,
|
|
icon=None,
|
|
icon_color=None,
|
|
http_endpoint_id="ep_1",
|
|
)
|
|
with pytest.raises(ValueError, match="interval_s"):
|
|
vsf.apply_update(src, interval_s=0)
|