feat(processed-audio-sources): phase 7 - testing and polish

Fix test_list_filters test (filter_id field name mismatch).
Add tests for audio filters, template store, and source store.
All 678 tests pass, ruff clean, tsc clean, esbuild clean.
No dead code remaining from old source types.
This commit is contained in:
2026-03-31 22:50:02 +03:00
parent 1ce0dc6c61
commit ce1f4847f3
11 changed files with 957 additions and 34 deletions
@@ -0,0 +1,180 @@
"""Tests for AudioProcessingTemplateStore — CRUD, template expansion, and cycle detection."""
import pytest
from wled_controller.core.filters.filter_instance import FilterInstance
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
from wled_controller.storage.database import Database
# Ensure all built-in audio filters are registered
import wled_controller.core.audio.filters # noqa: F401
@pytest.fixture
def apt_store(tmp_path):
"""Provide an AudioProcessingTemplateStore backed by a temp database."""
db = Database(tmp_path / "test.db")
store = AudioProcessingTemplateStore(db)
yield store
db.close()
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
class TestCRUD:
def test_create_and_get(self, apt_store):
t = apt_store.create_template(
name="My Template",
filters=[FilterInstance("gain", {"factor": 2.0})],
description="test desc",
tags=["audio"],
)
assert t.id.startswith("apt_")
assert t.name == "My Template"
assert t.description == "test desc"
assert t.tags == ["audio"]
assert len(t.filters) == 1
assert t.filters[0].filter_id == "gain"
fetched = apt_store.get_template(t.id)
assert fetched.name == "My Template"
def test_create_empty_filters(self, apt_store):
t = apt_store.create_template(name="Empty")
assert t.filters == []
def test_create_duplicate_name_raises(self, apt_store):
apt_store.create_template(name="UniqueA")
with pytest.raises(ValueError, match="already exists"):
apt_store.create_template(name="UniqueA")
def test_create_unknown_filter_raises(self, apt_store):
with pytest.raises(ValueError, match="Unknown audio filter type"):
apt_store.create_template(name="Bad", filters=[FilterInstance("nonexistent", {})])
def test_get_all(self, apt_store):
apt_store.create_template(name="T1")
apt_store.create_template(name="T2")
all_templates = apt_store.get_all_templates()
names = {t.name for t in all_templates}
assert "T1" in names
assert "T2" in names
def test_update_name_and_filters(self, apt_store):
t = apt_store.create_template(name="Original")
updated = apt_store.update_template(
t.id,
name="Renamed",
filters=[FilterInstance("gain", {"factor": 3.0})],
)
assert updated.name == "Renamed"
assert len(updated.filters) == 1
assert updated.filters[0].options["factor"] == 3.0
assert updated.updated_at > t.created_at
def test_update_nonexistent_raises(self, apt_store):
with pytest.raises(ValueError):
apt_store.update_template("apt_nonexistent", name="X")
def test_update_unknown_filter_raises(self, apt_store):
t = apt_store.create_template(name="Valid")
with pytest.raises(ValueError, match="Unknown audio filter type"):
apt_store.update_template(t.id, filters=[FilterInstance("nonexistent", {})])
def test_delete(self, apt_store):
t = apt_store.create_template(name="ToDelete")
apt_store.delete_template(t.id)
with pytest.raises(ValueError):
apt_store.get_template(t.id)
def test_delete_nonexistent_raises(self, apt_store):
with pytest.raises(ValueError):
apt_store.delete_template("apt_nonexistent")
def test_persistence_across_reload(self, tmp_path):
"""Templates survive store reconstruction (SQLite persistence)."""
db = Database(tmp_path / "persist.db")
store1 = AudioProcessingTemplateStore(db)
t = store1.create_template(
name="Persistent",
filters=[FilterInstance("gain", {"factor": 1.5})],
)
tid = t.id
db.close()
db2 = Database(tmp_path / "persist.db")
store2 = AudioProcessingTemplateStore(db2)
reloaded = store2.get_template(tid)
assert reloaded.name == "Persistent"
assert reloaded.filters[0].filter_id == "gain"
db2.close()
# ---------------------------------------------------------------------------
# Template composition (audio_filter_template)
# ---------------------------------------------------------------------------
class TestTemplateComposition:
def test_resolve_flat_filters(self, apt_store):
"""Non-template filters pass through unchanged."""
filters = [
FilterInstance("gain", {"factor": 2.0}),
FilterInstance("inverter", {}),
]
resolved = apt_store.resolve_filter_instances(filters)
assert len(resolved) == 2
assert resolved[0].filter_id == "gain"
assert resolved[1].filter_id == "inverter"
def test_resolve_nested_template(self, apt_store):
"""audio_filter_template reference is expanded recursively."""
inner = apt_store.create_template(
name="Inner",
filters=[FilterInstance("gain", {"factor": 3.0})],
)
outer_filters = [
FilterInstance("inverter", {}),
FilterInstance("audio_filter_template", {"template_id": inner.id}),
]
resolved = apt_store.resolve_filter_instances(outer_filters)
assert len(resolved) == 2
assert resolved[0].filter_id == "inverter"
assert resolved[1].filter_id == "gain"
def test_resolve_missing_template_skipped(self, apt_store):
"""References to nonexistent templates are silently skipped."""
filters = [
FilterInstance("gain", {}),
FilterInstance("audio_filter_template", {"template_id": "apt_nonexistent"}),
]
resolved = apt_store.resolve_filter_instances(filters)
assert len(resolved) == 1
assert resolved[0].filter_id == "gain"
def test_resolve_cycle_detection(self, apt_store):
"""Cycles in template composition are detected and broken."""
t1 = apt_store.create_template(
name="A",
filters=[FilterInstance("gain", {})],
)
t2 = apt_store.create_template(
name="B",
filters=[FilterInstance("audio_filter_template", {"template_id": t1.id})],
)
# Manually create a cycle: A references B
apt_store.update_template(
t1.id,
filters=[FilterInstance("audio_filter_template", {"template_id": t2.id})],
)
# Resolving should not infinite-loop; the cyclic reference is skipped
resolved = apt_store.resolve_filter_instances(t1.filters)
# Only gain from B's expansion of A (which itself is skipped due to cycle)
# The cycle is broken: A -> B -> A(skipped) -> gain never reached
# Actually: A has [ref to B], B has [ref to A]. Resolving A:
# - Visit A, expand B -> B has [ref to A], but A is already visited -> skip
# So result is empty.
assert len(resolved) == 0
@@ -0,0 +1,288 @@
"""Tests for AudioSourceStore — capture/processed source CRUD, chain resolution, cycle detection."""
import pytest
from wled_controller.storage.audio_source import CaptureAudioSource, ProcessedAudioSource
from wled_controller.storage.audio_source_store import AudioSourceStore, ResolvedAudioSource
from wled_controller.storage.database import Database
# Ensure audio filter registration for any template-related code
import wled_controller.core.audio.filters # noqa: F401
@pytest.fixture
def audio_store(tmp_path):
"""Provide an AudioSourceStore backed by a temp database."""
db = Database(tmp_path / "test.db")
store = AudioSourceStore(db)
yield store
db.close()
# ---------------------------------------------------------------------------
# CaptureAudioSource CRUD
# ---------------------------------------------------------------------------
class TestCaptureSource:
def test_create_capture(self, audio_store):
s = audio_store.create_source(
name="System Audio",
source_type="capture",
device_index=0,
is_loopback=True,
)
assert s.id.startswith("as_")
assert isinstance(s, CaptureAudioSource)
assert s.source_type == "capture"
assert s.device_index == 0
assert s.is_loopback is True
def test_create_capture_defaults(self, audio_store):
s = audio_store.create_source(name="Default", source_type="capture")
assert isinstance(s, CaptureAudioSource)
assert s.device_index == -1
assert s.is_loopback is True
def test_update_capture_device(self, audio_store):
s = audio_store.create_source(name="Mic", source_type="capture", device_index=0)
updated = audio_store.update_source(s.id, device_index=3)
assert isinstance(updated, CaptureAudioSource)
assert updated.device_index == 3
def test_delete_capture(self, audio_store):
s = audio_store.create_source(name="ToDelete", source_type="capture")
audio_store.delete_source(s.id)
with pytest.raises(ValueError):
audio_store.get_source(s.id)
# ---------------------------------------------------------------------------
# ProcessedAudioSource CRUD
# ---------------------------------------------------------------------------
class TestProcessedSource:
def test_create_processed(self, audio_store):
parent = audio_store.create_source(name="Parent", source_type="capture")
s = audio_store.create_source(
name="Processed",
source_type="processed",
audio_source_id=parent.id,
audio_processing_template_id="apt_test_001",
)
assert isinstance(s, ProcessedAudioSource)
assert s.audio_source_id == parent.id
assert s.audio_processing_template_id == "apt_test_001"
def test_create_processed_missing_parent_raises(self, audio_store):
with pytest.raises(ValueError, match="Parent audio source not found"):
audio_store.create_source(
name="Orphan",
source_type="processed",
audio_source_id="as_nonexistent",
audio_processing_template_id="apt_test_001",
)
def test_create_processed_no_source_id_raises(self, audio_store):
with pytest.raises(ValueError, match="audio_source_id"):
audio_store.create_source(
name="Bad",
source_type="processed",
audio_processing_template_id="apt_test_001",
)
def test_create_processed_no_template_raises(self, audio_store):
parent = audio_store.create_source(name="P", source_type="capture")
with pytest.raises(ValueError, match="audio_processing_template_id"):
audio_store.create_source(
name="Bad",
source_type="processed",
audio_source_id=parent.id,
)
def test_invalid_source_type_raises(self, audio_store):
with pytest.raises(ValueError, match="Invalid source type"):
audio_store.create_source(name="Bad", source_type="unknown")
def test_delete_parent_with_child_raises(self, audio_store):
parent = audio_store.create_source(name="Parent", source_type="capture")
audio_store.create_source(
name="Child",
source_type="processed",
audio_source_id=parent.id,
audio_processing_template_id="apt_test",
)
with pytest.raises(ValueError, match="referenced by"):
audio_store.delete_source(parent.id)
def test_delete_child_then_parent(self, audio_store):
parent = audio_store.create_source(name="Parent", source_type="capture")
child = audio_store.create_source(
name="Child",
source_type="processed",
audio_source_id=parent.id,
audio_processing_template_id="apt_test",
)
audio_store.delete_source(child.id)
audio_store.delete_source(parent.id)
assert len(audio_store.get_all_sources()) == 0
# ---------------------------------------------------------------------------
# Chain resolution
# ---------------------------------------------------------------------------
class TestChainResolution:
def test_resolve_capture_source(self, audio_store):
s = audio_store.create_source(
name="Mic",
source_type="capture",
device_index=2,
is_loopback=False,
audio_template_id="atpl_001",
)
resolved = audio_store.resolve_audio_source(s.id)
assert isinstance(resolved, ResolvedAudioSource)
assert resolved.device_index == 2
assert resolved.is_loopback is False
assert resolved.audio_template_id == "atpl_001"
assert resolved.audio_processing_template_ids == []
def test_resolve_processed_chain(self, audio_store):
capture = audio_store.create_source(name="Capture", source_type="capture", device_index=0)
proc = audio_store.create_source(
name="Processed",
source_type="processed",
audio_source_id=capture.id,
audio_processing_template_id="apt_tpl_A",
)
resolved = audio_store.resolve_audio_source(proc.id)
assert resolved.device_index == 0
assert resolved.audio_processing_template_ids == ["apt_tpl_A"]
def test_resolve_deep_chain(self, audio_store):
"""A -> B -> C (capture). Template IDs collected outermost first."""
capture = audio_store.create_source(name="C", source_type="capture", device_index=1)
b = audio_store.create_source(
name="B",
source_type="processed",
audio_source_id=capture.id,
audio_processing_template_id="apt_B",
)
a = audio_store.create_source(
name="A",
source_type="processed",
audio_source_id=b.id,
audio_processing_template_id="apt_A",
)
resolved = audio_store.resolve_audio_source(a.id)
assert resolved.device_index == 1
assert resolved.audio_processing_template_ids == ["apt_A", "apt_B"]
def test_resolve_nonexistent_raises(self, audio_store):
with pytest.raises(ValueError):
audio_store.resolve_audio_source("as_nonexistent")
# ---------------------------------------------------------------------------
# Cycle detection
# ---------------------------------------------------------------------------
class TestCycleDetection:
def test_update_to_self_raises(self, audio_store):
capture = audio_store.create_source(name="Cap", source_type="capture")
proc = audio_store.create_source(
name="Proc",
source_type="processed",
audio_source_id=capture.id,
audio_processing_template_id="apt_t",
)
with pytest.raises(ValueError, match="circular"):
audio_store.update_source(proc.id, audio_source_id=proc.id)
def test_cycle_in_chain_raises(self, audio_store):
capture = audio_store.create_source(name="C", source_type="capture")
a = audio_store.create_source(
name="A",
source_type="processed",
audio_source_id=capture.id,
audio_processing_template_id="apt_t",
)
b = audio_store.create_source(
name="B",
source_type="processed",
audio_source_id=a.id,
audio_processing_template_id="apt_t",
)
# Try to make A point to B, creating A -> B -> A cycle
with pytest.raises(ValueError, match="circular"):
audio_store.update_source(a.id, audio_source_id=b.id)
# ---------------------------------------------------------------------------
# Reference query helpers
# ---------------------------------------------------------------------------
class TestReferenceHelpers:
def test_get_sources_referencing_template(self, audio_store):
capture = audio_store.create_source(name="Cap", source_type="capture")
audio_store.create_source(
name="P1",
source_type="processed",
audio_source_id=capture.id,
audio_processing_template_id="apt_shared",
)
audio_store.create_source(
name="P2",
source_type="processed",
audio_source_id=capture.id,
audio_processing_template_id="apt_shared",
)
audio_store.create_source(
name="P3",
source_type="processed",
audio_source_id=capture.id,
audio_processing_template_id="apt_other",
)
refs = audio_store.get_sources_referencing_template("apt_shared")
assert len(refs) == 2
names = {r.name for r in refs}
assert names == {"P1", "P2"}
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
class TestPersistence:
def test_sources_survive_reload(self, tmp_path):
db = Database(tmp_path / "persist.db")
store = AudioSourceStore(db)
s = store.create_source(name="Persisted", source_type="capture", device_index=5)
sid = s.id
db.close()
db2 = Database(tmp_path / "persist.db")
store2 = AudioSourceStore(db2)
reloaded = store2.get_source(sid)
assert reloaded.name == "Persisted"
assert isinstance(reloaded, CaptureAudioSource)
assert reloaded.device_index == 5
db2.close()
# ---------------------------------------------------------------------------
# Name uniqueness
# ---------------------------------------------------------------------------
class TestNameUniqueness:
def test_duplicate_name_raises(self, audio_store):
audio_store.create_source(name="MySource", source_type="capture")
with pytest.raises(ValueError, match="already exists"):
audio_store.create_source(name="MySource", source_type="capture")