refactor(value-source): per-type factories for create / update dispatch
ValueSourceStore.create_source used to be a ~260-line if/elif chain
over 14 source_type strings; update_source did the same dance again
with 14 isinstance branches (audit finding C7 store-side). Each
branch duplicated the common-fields scaffold and the per-type
defaulting + validation logic.
Lift each per-type create / update body into a free function in a
new ``storage.value_source_factories`` module:
* ``CREATE_BUILDERS[source_type]`` — owns defaulting + per-type
validation (HA needs ha_source_id + entity_id; gradient_map
needs value_source_id; system_metrics validates against
VALID_SYSTEM_METRICS; http rejects interval_s < 1; the two
adaptive_* sub-modes route to the same AdaptiveValueSource
class with different source_type discriminators).
* ``UPDATE_APPLIERS[source_type]`` — mirrors the above on the
update side; ``resolve_ref`` is applied to cross-entity
references so empty-string clears keep working.
* ``build_source(...)`` / ``apply_update(source, **kwargs)`` are
the public entry points the store calls.
* ``_assert_factory_coverage()`` runs at module import and
requires BOTH registries to match storage's _VALUE_SOURCE_MAP
exactly.
The store's ``create_source`` shrinks from ~260 lines to ~25;
``update_source`` from ~200 lines to ~40.
Tests: 14 new tests cover registry coverage in both directions
plus drift assertions, representative builder paths (static /
adaptive_time / adaptive_scene / ha_entity / http / unknown),
the AdaptiveValueSource dual-source-type discriminator, and
several applier paths including ``**_`` swallowing unknown kwargs
and HTTP zero-interval rejection. 47 existing value-source store
tests stay green; 769 storage / core / api tests in aggregate.
Ruff clean.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
Reference in New Issue
Block a user