2b5dac2c42
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.
156 lines
6.4 KiB
Python
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()
|