9afa3484f4
- Added `waveform_key` and `event_timestamp` columns to `CachedEvent` and `CachedWaveform` for integrity verification. - Implemented logic to flush the cache when a mismatch in (waveform_key, event_timestamp) is detected during event and waveform updates. - Enhanced `set_events` and `set_waveform` methods to check for mismatches and trigger cache eviction as necessary. - Introduced a new `LiveCache` class to manage in-memory caching of live device data, separating it from the server logic for better testability. - Added tests to verify the correctness of cache invalidation logic, particularly for post-erase key reuse scenarios. - Updated web application to include a "Force refresh" toggle, allowing users to bypass the cache and re-fetch data from the device.
210 lines
7.4 KiB
Python
210 lines
7.4 KiB
Python
"""
|
|
test_cache_invalidation.py — verify post-erase key-reuse correctness.
|
|
|
|
The device's event-key counter resets to 0x01110000 after every memory erase,
|
|
so a bare-key dedup (the old behaviour) silently treats a freshly-recorded
|
|
event 0 as if it were the previously-downloaded one. These tests exercise
|
|
the (key, timestamp)-based eviction logic in:
|
|
|
|
- bridges/ach_server.py (state-file migration + force flag)
|
|
- sfm/server.py (_LiveCache.set_events / set_waveform)
|
|
|
|
Run:
|
|
python tests/test_cache_invalidation.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import pytest
|
|
except ImportError:
|
|
pytest = None # type: ignore
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
# ── ACH state migration ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_ach_state_legacy_migration(tmp_path: Path):
|
|
"""
|
|
Legacy v1 state with a `downloaded_keys` list is migrated on _load_state
|
|
to the v2 `downloaded_events` dict. All legacy keys come back with empty
|
|
timestamps so the (key, ts) compare in get_events() always falls through
|
|
to a fresh download.
|
|
"""
|
|
from bridges.ach_server import _load_state
|
|
|
|
state_path = tmp_path / "ach_state.json"
|
|
legacy = {
|
|
"BE11529": {
|
|
"downloaded_keys": ["01110000", "0111245a"],
|
|
"max_downloaded_key": "0111245a",
|
|
"last_seen": "2026-04-11T01:04:36",
|
|
"serial": "BE11529",
|
|
"peer": "63.43.212.232:51920",
|
|
},
|
|
}
|
|
state_path.write_text(json.dumps(legacy))
|
|
|
|
migrated = _load_state(state_path)
|
|
|
|
unit = migrated["BE11529"]
|
|
assert "downloaded_keys" not in unit
|
|
assert unit["downloaded_events"] == {
|
|
"01110000": "",
|
|
"0111245a": "",
|
|
}
|
|
# max_downloaded_key is preserved verbatim
|
|
assert unit["max_downloaded_key"] == "0111245a"
|
|
|
|
|
|
def test_ach_state_v2_passes_through(tmp_path: Path):
|
|
"""A v2 state file is returned verbatim — no migration touches it."""
|
|
from bridges.ach_server import _load_state
|
|
|
|
state_path = tmp_path / "ach_state.json"
|
|
v2 = {
|
|
"BE11529": {
|
|
"downloaded_events": {
|
|
"01110000": "2026-04-15T14:23:45",
|
|
"0111245a": "2026-04-16T09:01:12",
|
|
},
|
|
"max_downloaded_key": "0111245a",
|
|
"serial": "BE11529",
|
|
},
|
|
}
|
|
state_path.write_text(json.dumps(v2))
|
|
|
|
loaded = _load_state(state_path)
|
|
assert loaded["BE11529"]["downloaded_events"] == v2["BE11529"]["downloaded_events"]
|
|
|
|
|
|
def test_ach_state_missing_returns_empty(tmp_path: Path):
|
|
"""Nonexistent state path → empty dict (not an error)."""
|
|
from bridges.ach_server import _load_state
|
|
assert _load_state(tmp_path / "absent.json") == {}
|
|
|
|
|
|
# ── _LiveCache eviction ───────────────────────────────────────────────────────
|
|
|
|
|
|
def _ev(index: int, key: str, ts: str) -> dict:
|
|
return {"index": index, "waveform_key": key, "timestamp": ts}
|
|
|
|
|
|
def test_live_cache_set_events_no_eviction_when_keys_match():
|
|
"""No flush when incoming events match the cached (key, ts) at each index."""
|
|
from sfm.live_cache import LiveCache as _LiveCache
|
|
|
|
c = _LiveCache()
|
|
conn = "tcp:1.2.3.4:12345"
|
|
c.set_events(conn, 2, [_ev(0, "01110000", "2026-04-15T14:23:45"),
|
|
_ev(1, "0111245a", "2026-04-16T09:01:12")])
|
|
c.set_waveform(conn, 0, _ev(0, "01110000", "2026-04-15T14:23:45"))
|
|
|
|
# Same events again — must not flush.
|
|
c.set_events(conn, 2, [_ev(0, "01110000", "2026-04-15T14:23:45"),
|
|
_ev(1, "0111245a", "2026-04-16T09:01:12")])
|
|
|
|
assert c._events[conn][0] == 2
|
|
assert (conn, 0) in c._waveforms
|
|
|
|
|
|
def test_live_cache_set_events_flushes_on_post_erase_collision():
|
|
"""
|
|
Index 0 keeps the same key (01110000 reuses) but the timestamp differs
|
|
→ device was erased + re-recorded → flush all events + waveforms for the
|
|
device.
|
|
"""
|
|
from sfm.live_cache import LiveCache as _LiveCache
|
|
|
|
c = _LiveCache()
|
|
conn = "tcp:1.2.3.4:12345"
|
|
# First "session": index 0 key=01110000 ts=2026-04-15.
|
|
c.set_events(conn, 1, [_ev(0, "01110000", "2026-04-15T14:23:45")])
|
|
c.set_waveform(conn, 0, _ev(0, "01110000", "2026-04-15T14:23:45"))
|
|
assert (conn, 0) in c._waveforms
|
|
|
|
# Second "session" after erase: index 0 still key=01110000 but new ts.
|
|
c.set_events(conn, 1, [_ev(0, "01110000", "2026-05-06T12:34:56")])
|
|
|
|
# Stale waveform for index 0 must have been flushed by the eviction path
|
|
# before the new event was inserted. The new events list IS in cache but
|
|
# the cached waveform from the prior session is gone.
|
|
assert (conn, 0) not in c._waveforms
|
|
assert c._events[conn][1][0]["timestamp"] == "2026-05-06T12:34:56"
|
|
|
|
|
|
def test_live_cache_set_waveform_flushes_on_mismatch():
|
|
"""set_waveform alone should also evict when (key, ts) differs."""
|
|
from sfm.live_cache import LiveCache as _LiveCache
|
|
|
|
c = _LiveCache()
|
|
conn = "tcp:1.2.3.4:12345"
|
|
c.set_waveform(conn, 0, _ev(0, "01110000", "2026-04-15T14:23:45"))
|
|
c.set_waveform(conn, 1, _ev(1, "0111245a", "2026-04-16T09:01:12"))
|
|
|
|
# Index 0 swap: same key, new timestamp.
|
|
c.set_waveform(conn, 0, _ev(0, "01110000", "2026-05-06T12:34:56"))
|
|
|
|
# Index 1's stale waveform must be flushed — keeping it would mix eras.
|
|
assert (conn, 1) not in c._waveforms
|
|
# The newly-inserted index 0 entry is what's there.
|
|
assert c._waveforms[(conn, 0)]["timestamp"] == "2026-05-06T12:34:56"
|
|
|
|
|
|
def test_live_cache_partial_signature_does_not_flush():
|
|
"""
|
|
If incoming event lacks waveform_key OR timestamp, we cannot prove a
|
|
mismatch — eviction must NOT trigger. Avoids spurious flushes from
|
|
legacy / partial event shapes.
|
|
"""
|
|
from sfm.live_cache import LiveCache as _LiveCache
|
|
|
|
c = _LiveCache()
|
|
conn = "tcp:1.2.3.4:12345"
|
|
c.set_waveform(conn, 0, _ev(0, "01110000", "2026-04-15T14:23:45"))
|
|
|
|
# Incoming entry missing the timestamp — cannot prove a mismatch.
|
|
c.set_waveform(conn, 0, {"index": 0, "waveform_key": "01110000"})
|
|
|
|
# Cache should contain the new entry; the implementation overwrites
|
|
# the index-0 row but does NOT flush other indices. Since there are no
|
|
# other indices in this test, just check the entry exists.
|
|
assert (conn, 0) in c._waveforms
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if pytest is not None:
|
|
pytest.main([__file__, "-v"])
|
|
else:
|
|
import inspect
|
|
import traceback as _tb
|
|
|
|
passed = failed = 0
|
|
for _name, _fn in sorted(globals().items()):
|
|
if not _name.startswith("test_") or not callable(_fn):
|
|
continue
|
|
try:
|
|
_sig = inspect.signature(_fn)
|
|
if "tmp_path" in _sig.parameters:
|
|
with tempfile.TemporaryDirectory() as _td:
|
|
_fn(Path(_td))
|
|
else:
|
|
_fn()
|
|
print(f"PASS {_name}")
|
|
passed += 1
|
|
except Exception:
|
|
print(f"FAIL {_name}")
|
|
_tb.print_exc()
|
|
failed += 1
|
|
print(f"\n{passed} passed, {failed} failed")
|
|
sys.exit(0 if failed == 0 else 1)
|