Files
ledgrab/server/tests/storage/test_asset_store.py
T
alexei.dolgolyov 2b5dac2c42
Build Android APK / build-android (push) Failing after 1m44s
Lint & Test / test (push) Successful in 4m22s
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.
2026-04-21 14:58:35 +03:00

156 lines
6.4 KiB
Python

"""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()