refactor(storage,processing): kind registries + versioned data migrations
Two CRITICAL data-safety bugs from the architecture audit and the two
worst parallel-change problems are fixed in one coherent pass.
Audit findings addressed:
- C2 silent CSS response fallback. The previous _RESPONSE_MAP fell
through to a fabricated PictureCSSResponse whenever a source
class lacked an entry; in particular game_event sources were
silently mis-shaped. Now: GameEventCSSResponse/Create/Update
schemas exist, _RESPONSE_MAP is re-keyed by source_type string,
an import-time _assert_response_map_coverage() requires symmetric
agreement with storage._SOURCE_TYPE_MAP, and the runtime path
raises instead of fabricating a response.
- C11 string-replace JSON migration. ColorStripStore used
blob.replace('"source_type": "static"', '"source_type":
"single_color"') which can corrupt unrelated substrings (e.g.
an animation type named "static_wave") and provides no audit,
no transaction, no idempotency. Replaced with
storage.data_migrations.MigrationRunner backed by a
data_migrations audit table. Each migration runs inside one
db.transaction() that covers the applied-check, the apply(),
and the audit-INSERT — partial failures roll back atomically.
StaticToSingleColorMigration parses each row with json.loads
and mutates only the source_type field. Frozen-write databases
skip with a warning.
- C3+C4 color-strip stream dispatch. The 7-branch elif in
ColorStripStreamManager.acquire() and the duplicate one in
ws_stream._create_stream() now share a single STREAM_BUILDERS
registry in core.processing.color_strip_kinds, keyed by
source.source_type. Both call sites populate a StreamDeps bag
and delegate to build_stream(). _assert_stream_kind_coverage()
asserts at import that STREAM_BUILDERS plus SHARABLE_KINDS
partitions storage._SOURCE_TYPE_MAP. ws_stream's preview path
wraps each FastAPI-DI getter in _safe() so non-audio previews
no longer crash when audio/CSPT stores are not wired.
- C6+C7 value stream dispatch. The 14-branch isinstance ladder in
ValueStreamManager._create_stream and its silent
StaticValueStream(value=1.0) fallback are replaced by
core.processing.value_kinds.STREAM_BUILDERS, keyed by
source_type string (so AdaptiveValueSource's adaptive_time and
adaptive_scene route to different builders correctly). The
manager retains only the SyncClockRuntime pre-acquisition step
for animated_color (kinds needing this are listed explicitly
in NEEDS_CLOCK_RUNTIME). Symmetric coverage assertion plus a
separate assertion that NEEDS_CLOCK_RUNTIME is a subset of the
registry.
Bundled in: the static->single_color rename plus the HTTPValueStream
/ http_endpoint introduction that were already in flight on this
branch share these files; the registry refactor naturally absorbs
both via the new "single_color" / "static" alias entries and the
_build_http builder.
Tests: 26 new tests cover response-map coverage drift, migration
runner audit-table mechanics + transactional rollback +
frozen-write skip, and the two stream-builder registries. 343
existing storage / API / e2e tests stay green. Ruff clean.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""Regression tests for the CSS response builder dispatch.
|
||||
|
||||
These lock in the safety net introduced when the silent "fallback to a fake
|
||||
PictureCSSResponse" branch was removed:
|
||||
|
||||
* every concrete ColorStripSource subclass registered in ``_SOURCE_TYPE_MAP``
|
||||
must have a builder in ``_RESPONSE_MAP`` (verified at module import); and
|
||||
* the previously-missing ``GameEventColorStripSource`` round-trips through
|
||||
the response builder into the matching ``GameEventCSSResponse`` schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.api.routes.color_strip_sources import _helpers
|
||||
from ledgrab.api.schemas.color_strip_sources import GameEventCSSResponse
|
||||
from ledgrab.storage.color_strip_source import (
|
||||
ColorStripSource,
|
||||
GameEventColorStripSource,
|
||||
)
|
||||
|
||||
|
||||
def test_response_map_covers_every_registered_source_type():
|
||||
"""Every source_type in _SOURCE_TYPE_MAP has a response builder."""
|
||||
storage_kinds = set(_helpers._STORAGE_TYPE_MAP.keys())
|
||||
builder_kinds = set(_helpers._RESPONSE_MAP.keys())
|
||||
missing = storage_kinds - builder_kinds
|
||||
assert not missing, f"_RESPONSE_MAP is missing builders for: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_assert_helper_raises_when_a_kind_is_unregistered(monkeypatch):
|
||||
"""Adding a kind to the storage registry without a builder is caught."""
|
||||
|
||||
class _PhantomColorStripSource(ColorStripSource):
|
||||
pass
|
||||
|
||||
monkeypatch.setitem(_helpers._STORAGE_TYPE_MAP, "phantom", _PhantomColorStripSource)
|
||||
with pytest.raises(RuntimeError, match="phantom"):
|
||||
_helpers._assert_response_map_coverage()
|
||||
|
||||
|
||||
def test_game_event_source_serialises_to_game_event_response():
|
||||
"""A GameEventColorStripSource is rendered as GameEventCSSResponse (not the
|
||||
fake PictureCSSResponse that the silent fallback used to return).
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
source = GameEventColorStripSource(
|
||||
id="css_gev0001",
|
||||
name="Counter-Strike feedback",
|
||||
source_type="game_event",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
game_integration_id="gi_cs2_main",
|
||||
event_mappings=[{"event": "hit", "effect": "flash"}],
|
||||
led_count=60,
|
||||
)
|
||||
|
||||
response = _helpers._css_to_response(source)
|
||||
|
||||
assert isinstance(response, GameEventCSSResponse)
|
||||
assert response.source_type == "game_event"
|
||||
assert response.game_integration_id == "gi_cs2_main"
|
||||
assert response.event_mappings == [{"event": "hit", "effect": "flash"}]
|
||||
assert response.led_count == 60
|
||||
|
||||
|
||||
def test_unregistered_source_type_raises_in_css_to_response():
|
||||
"""Reaching ``_css_to_response`` with an unmapped source_type raises loudly."""
|
||||
|
||||
class _UnregisteredCSS(ColorStripSource):
|
||||
pass
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
source = _UnregisteredCSS(
|
||||
id="css_xxx",
|
||||
name="rogue",
|
||||
source_type="rogue_unregistered",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="No CSS response builder"):
|
||||
_helpers._css_to_response(source)
|
||||
Reference in New Issue
Block a user