feat(devices): BLE LED controller support (SP110E/Triones/Zengge/Govee)
End-to-end BLE streaming: provider + client + per-protocol wire encoders with whole-strip averaging, desktop (bleak) and Android (Kotlin BleBridge via Chaquopy) transports, discovery with protocol-family detection that auto-fills the UI, throttled not-connected warning + 10 s reconnect cooldown so a dropped link no longer stalls the pipeline at ~30 s/frame, and an explicit asyncio.wait_for wrapper around bleak connect() since the WinRT backend doesn't always honor the timeout kwarg. Also rewrites server/restart.ps1 to be parameterized (-Port / -Module / -PythonVersion / timeouts / -Quiet), pick the right interpreter via the py launcher, pre-flight the target module, poll port readiness on both shutdown and startup, redirect child stdout/stderr so Start-Process doesn't hang on inherited Git-Bash handles, and return proper exit codes. Rolls in concurrent work: Android BLE permissions + launcher icons + ru/zh resources, Chaquopy-safe value_stream psutil fallback, setup-required modal, asset-store test coverage, and misc system/config touch-ups.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""Tests for AssetStore — focusing on self-heal behavior on startup.
|
||||
|
||||
Regression guard for the "restored backup loses notification sounds" class
|
||||
of bug: when the SQLite DB is restored from elsewhere but the ``assets/``
|
||||
directory hasn't moved with it, prebuilt sound files end up with stale
|
||||
DB rows pointing at missing files. ``heal_prebuilt_files`` is the safety
|
||||
net.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.storage.database import Database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_with_prebuilt(tmp_path):
|
||||
"""AssetStore seeded with the shipped prebuilt sounds."""
|
||||
db = Database(tmp_path / "test.db")
|
||||
assets_dir = tmp_path / "assets"
|
||||
prebuilt_dir = tmp_path / "prebuilt"
|
||||
prebuilt_dir.mkdir()
|
||||
# Minimal valid WAV-ish bytes — content doesn't matter for these tests,
|
||||
# only that the file exists and is copied around correctly.
|
||||
for name in ("alert.wav", "bell.wav"):
|
||||
(prebuilt_dir / name).write_bytes(b"RIFF\x00\x00\x00\x00WAVEfmt " + name.encode())
|
||||
|
||||
store = AssetStore(db, assets_dir)
|
||||
imported = store.import_prebuilt_sounds(prebuilt_dir)
|
||||
assert len(imported) == 2
|
||||
yield store, prebuilt_dir, assets_dir
|
||||
db.close()
|
||||
|
||||
|
||||
class TestHealPrebuiltFiles:
|
||||
def test_heals_missing_stored_file(self, store_with_prebuilt):
|
||||
store, prebuilt_dir, assets_dir = store_with_prebuilt
|
||||
alert = next(a for a in store.get_all_assets() if a.filename == "alert.wav")
|
||||
|
||||
# Simulate a partial restore: DB row survived, file vanished.
|
||||
(assets_dir / alert.stored_filename).unlink()
|
||||
assert store.get_file_path(alert.id) is None
|
||||
|
||||
healed = store.heal_prebuilt_files(prebuilt_dir)
|
||||
|
||||
assert [a.id for a in healed] == [alert.id]
|
||||
restored_path = assets_dir / alert.stored_filename
|
||||
assert restored_path.exists()
|
||||
# File content round-trips from the prebuilt source.
|
||||
assert restored_path.read_bytes() == (prebuilt_dir / "alert.wav").read_bytes()
|
||||
# Size metadata is updated to match the re-copied file.
|
||||
assert store.get_asset(alert.id).size_bytes == restored_path.stat().st_size
|
||||
|
||||
def test_idempotent_when_files_present(self, store_with_prebuilt):
|
||||
store, prebuilt_dir, _ = store_with_prebuilt
|
||||
first = store.heal_prebuilt_files(prebuilt_dir)
|
||||
second = store.heal_prebuilt_files(prebuilt_dir)
|
||||
assert first == [] and second == []
|
||||
|
||||
def test_skips_soft_deleted_prebuilt(self, store_with_prebuilt):
|
||||
store, prebuilt_dir, assets_dir = store_with_prebuilt
|
||||
alert = next(a for a in store.get_all_assets() if a.filename == "alert.wav")
|
||||
store.delete_asset(alert.id) # prebuilt → soft-delete; file removed
|
||||
assert store.get_asset(alert.id).deleted is True
|
||||
|
||||
healed = store.heal_prebuilt_files(prebuilt_dir)
|
||||
# Heal must not resurrect a soft-deleted asset — that is
|
||||
# `restore_prebuilt`'s job and is an explicit user action.
|
||||
assert healed == []
|
||||
assert not (assets_dir / alert.stored_filename).exists()
|
||||
|
||||
def test_returns_empty_when_source_also_missing(self, store_with_prebuilt):
|
||||
"""If both the stored file and the prebuilt source are gone, the
|
||||
heal is a no-op — the asset stays broken and the method does not
|
||||
raise (so startup is not aborted)."""
|
||||
store, prebuilt_dir, assets_dir = store_with_prebuilt
|
||||
alert = next(a for a in store.get_all_assets() if a.filename == "alert.wav")
|
||||
(assets_dir / alert.stored_filename).unlink()
|
||||
(prebuilt_dir / "alert.wav").unlink()
|
||||
|
||||
healed = store.heal_prebuilt_files(prebuilt_dir)
|
||||
|
||||
assert healed == []
|
||||
# Unrelated assets still heal correctly.
|
||||
bell = next(a for a in store.get_all_assets() if a.filename == "bell.wav")
|
||||
(assets_dir / bell.stored_filename).unlink()
|
||||
healed = store.heal_prebuilt_files(prebuilt_dir)
|
||||
assert [a.id for a in healed] == [bell.id]
|
||||
|
||||
def test_import_prebuilt_sounds_runs_heal(self, tmp_path):
|
||||
"""End-to-end: restoring a DB and re-running startup heals missing files."""
|
||||
# First run: import both sounds normally.
|
||||
db = Database(tmp_path / "first.db")
|
||||
assets_dir = tmp_path / "assets"
|
||||
prebuilt_dir = tmp_path / "prebuilt"
|
||||
prebuilt_dir.mkdir()
|
||||
(prebuilt_dir / "alert.wav").write_bytes(b"hello-alert")
|
||||
|
||||
store = AssetStore(db, assets_dir)
|
||||
imported = store.import_prebuilt_sounds(prebuilt_dir)
|
||||
assert len(imported) == 1
|
||||
alert_stored = imported[0].stored_filename
|
||||
db.close()
|
||||
|
||||
# Simulate partial restore: file gone, DB row preserved.
|
||||
(assets_dir / alert_stored).unlink()
|
||||
|
||||
# Second run: reopen the same DB + assets dir.
|
||||
db2 = Database(tmp_path / "first.db")
|
||||
store2 = AssetStore(db2, assets_dir)
|
||||
store2.import_prebuilt_sounds(prebuilt_dir)
|
||||
assert (assets_dir / alert_stored).exists()
|
||||
db2.close()
|
||||
|
||||
|
||||
class TestWarnMissingCustomFiles:
|
||||
def test_reports_custom_asset_with_missing_file(self, tmp_path):
|
||||
db = Database(tmp_path / "test.db")
|
||||
assets_dir = tmp_path / "assets"
|
||||
store = AssetStore(db, assets_dir)
|
||||
|
||||
asset = store.create_asset(
|
||||
name="User Sound",
|
||||
filename="user.wav",
|
||||
file_data=b"RIFF\x00\x00\x00\x00WAVEfmt user",
|
||||
)
|
||||
(assets_dir / asset.stored_filename).unlink()
|
||||
|
||||
missing = store.warn_missing_custom_files()
|
||||
|
||||
assert [a.id for a in missing] == [asset.id]
|
||||
db.close()
|
||||
|
||||
def test_quiet_when_all_custom_files_present(self, tmp_path):
|
||||
db = Database(tmp_path / "test.db")
|
||||
store = AssetStore(db, tmp_path / "assets")
|
||||
store.create_asset(name="User", filename="user.wav", file_data=b"data123")
|
||||
assert store.warn_missing_custom_files() == []
|
||||
db.close()
|
||||
|
||||
def test_ignores_prebuilt_and_soft_deleted(self, tmp_path):
|
||||
db = Database(tmp_path / "test.db")
|
||||
assets_dir = tmp_path / "assets"
|
||||
prebuilt_dir = tmp_path / "prebuilt"
|
||||
prebuilt_dir.mkdir()
|
||||
(prebuilt_dir / "alert.wav").write_bytes(b"prebuilt-alert")
|
||||
|
||||
store = AssetStore(db, assets_dir)
|
||||
store.import_prebuilt_sounds(prebuilt_dir)
|
||||
prebuilt_asset = store.get_all_assets()[0]
|
||||
(assets_dir / prebuilt_asset.stored_filename).unlink()
|
||||
|
||||
# Missing prebuilt files are NOT reported here (that's heal's job).
|
||||
assert store.warn_missing_custom_files() == []
|
||||
db.close()
|
||||
Reference in New Issue
Block a user