Files
MeshTerm/tests/test_device_state.py
T
Jean-Pierre MartineauandClaude Fable 5 19ac17a262 Heard ages: a sender's future clock no longer pins the list
A contact's last_advert is stamped by the advertising node's own clock;
a mis-set one lands in the future, where the age clamp read it as a
permanent "now" and the heard sort pinned it to the top — for as long
as the bogus stamp stayed ahead of the wall clock, since the firmware
only ever advances the field (its replay filter).

advert_time (models.py) is now THE converter for every last_advert
epoch — the live contact table and the cross-session store both pass
through it — and refuses a stamp more than 5 minutes ahead as unknown.
The devstate contacts fetch then fills the gap from our own recorded
receptions (stamped by our clock), so a refused contact we really do
hear shows an honest age, and one we never heard reads never.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:56:21 -04:00

238 lines
9.0 KiB
Python

"""DeviceState session-cache tests: read once, serve cached, invalidate on write.
The cache exists to keep screen navigation off the slow radio round-trips (contacts,
self-info, the channel probe). These tests pin the two behaviours that make it correct:
stable facts are fetched once and reused until an in-app write invalidates them, and the
contacts list refreshes in the background once it ages past the TTL without ever blocking a
read. A fake device counts round-trips so "served from cache" is assertable without hardware.
"""
from __future__ import annotations
import asyncio
import time
from datetime import datetime, timezone
from types import SimpleNamespace
from meshterm.core.models import Contact
from meshterm.services.device_state import DeviceState, _CONTACTS_TTL_S
class FakeDevice:
"""A stand-in device that counts how often each cached read hits the wire."""
def __init__(self) -> None:
self.contacts_calls = 0
self.self_info_calls = 0
self.mode_calls = 0
self.channel_calls = 0
self.capacity_calls = 0
self.contact_rows: "list[Contact] | None" = None # fixed table, when a test needs one
async def get_contacts(self) -> list:
self.contacts_calls += 1
if self.contact_rows is not None:
return list(self.contact_rows)
return [Contact(name=f"contact-{self.contacts_calls}")] # fresh identity per fetch, to spot refreshes
async def get_self_info(self) -> dict:
self.self_info_calls += 1
return {"name": "node", "tx_power": 20}
async def get_path_hash_mode(self) -> int:
self.mode_calls += 1
return 2
async def get_channel(self, idx: int):
# One configured slot at index 0, then the firmware "rejects" index 1 to end the probe.
self.channel_calls += 1
if idx == 0:
return {"channel_name": "public", "channel_secret": b"\x00" * 16}
raise RuntimeError("out of range")
async def channel_capacity(self) -> int:
# A fixed hardware constant; the cache must read it exactly once for the session.
self.capacity_calls += 1
return 8
def _devstate(device: FakeDevice, heard: tuple = ()) -> DeviceState:
"""A DeviceState over a fake ctx: device(), a silent logger, and a heard-node history."""
async def device_getter():
return device
ctx = SimpleNamespace(
device=device_getter,
log=SimpleNamespace(debug=lambda *a, **k: None),
repo=SimpleNamespace(heard_nodes=lambda: list(heard)),
)
return DeviceState(ctx) # type: ignore[arg-type]
def test_stable_facts_are_fetched_once_and_served_from_cache() -> None:
"""self-info, path-hash mode, and channels read the radio once, then reuse the value."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
for _ in range(3):
assert (await ds.self_info())["name"] == "node"
assert await ds.path_hash_mode() == 2
assert [s.name for s in await ds.channel_slots()] == ["public"]
asyncio.run(run())
assert dev.self_info_calls == 1
assert dev.mode_calls == 1
# The channel probe ran once (idx 0 ok, idx 1 rejected) and was cached wholesale.
assert dev.channel_calls == 2
def test_contacts_served_from_cache_within_ttl() -> None:
"""Repeated contacts reads inside the TTL hit the wire exactly once."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
first = await ds.contacts()
for _ in range(5):
assert await ds.contacts() is first # same cached list object
asyncio.run(run())
assert dev.contacts_calls == 1
def test_contacts_refresh_in_background_past_ttl() -> None:
"""Past the TTL a read returns the stale list instantly and refreshes behind it."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
stale = await ds.contacts() # first fetch
assert dev.contacts_calls == 1
# Age the cache past the TTL, then read: the read must return immediately (the stale
# copy) and schedule a background refresh rather than block on the slow call.
ds._contacts_at = time.monotonic() - _CONTACTS_TTL_S - 1
served = await ds.contacts()
assert served is stale # served the old list, did not block on a re-fetch
# Let the scheduled background refresh run.
await asyncio.gather(*list(ds._tasks))
assert dev.contacts_calls == 2 # refreshed behind the read
assert (await ds.contacts())[0].name == "contact-2" # now serving the fresh list
asyncio.run(run())
def test_missing_heard_times_fill_from_recorded_receptions() -> None:
"""A contact with no plausible advert time gets the time *we* last heard it, if any.
The device's ``last_advert`` is stamped by the sender's clock and refused upstream when
implausible (see ``models.advert_time``), so a contact can arrive with no heard time
even though our own observation history holds first-hand receptions. The cache fills
that gap once, at the fetch — and never overrides a plausible device-reported time.
"""
dev = FakeDevice()
device_says = datetime(2026, 7, 28, 9, 0, tzinfo=timezone.utc)
we_heard = datetime(2026, 7, 29, 12, 0, tzinfo=timezone.utc)
dev.contact_rows = [
Contact(name="Bogus-Clock", public_key="aa" * 32, key_prefix="aa" * 6),
Contact(name="Honest", public_key="bb" * 32, key_prefix="bb" * 6, last_seen=device_says),
Contact(name="Quiet", public_key="cc" * 32, key_prefix="cc" * 6),
]
ds = _devstate(dev, heard=(SimpleNamespace(node="aa" * 6, last_seen=we_heard),))
async def run() -> None:
by_name = {c.name: c for c in await ds.contacts()}
assert by_name["Bogus-Clock"].last_seen == we_heard # filled from our own history
assert by_name["Honest"].last_seen == device_says # a plausible device time stands
assert by_name["Quiet"].last_seen is None # truly never heard stays never
asyncio.run(run())
def test_force_bypasses_the_contacts_cache() -> None:
"""A forced read always re-fetches, for the rare caller that needs it fresh."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
await ds.contacts()
await ds.contacts(force=True)
asyncio.run(run())
assert dev.contacts_calls == 2
def test_invalidation_forces_a_re_read() -> None:
"""Each invalidate drops exactly its own entry so the next read re-fetches it."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
await ds.self_info(); await ds.path_hash_mode(); await ds.channel_slots(); await ds.contacts()
# invalidate_config drops self-info + path-hash mode together (the config editor's write).
ds.invalidate_config()
await ds.self_info(); await ds.path_hash_mode()
assert dev.self_info_calls == 2 and dev.mode_calls == 2
# channels and contacts were untouched by that invalidation.
assert dev.channel_calls == 2 and dev.contacts_calls == 1
ds.invalidate_channels()
await ds.channel_slots()
assert dev.channel_calls == 4 # probed again
asyncio.run(run())
def test_reset_clears_everything() -> None:
"""A reconnect's reset drops the whole cache so every fact is re-read on next use."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
await ds.self_info(); await ds.contacts(); await ds.path_hash_mode(); await ds.channel_capacity()
ds.reset()
await ds.self_info(); await ds.contacts(); await ds.path_hash_mode(); await ds.channel_capacity()
asyncio.run(run())
assert dev.self_info_calls == 2
assert dev.contacts_calls == 2
assert dev.mode_calls == 2
assert dev.capacity_calls == 2 # capacity is a hardware constant, but a reconnect re-reads it
def test_channel_capacity_is_fetched_once_and_served_from_cache() -> None:
"""Capacity is a hardware constant: probed once, then reused for the session."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
for _ in range(3):
assert await ds.channel_capacity() == 8
asyncio.run(run())
assert dev.capacity_calls == 1
def test_prewarm_fills_the_slow_caches_off_the_read_path() -> None:
"""prewarm() warms contacts, channels, and capacity so the first read hits no wire."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
ds.prewarm()
await asyncio.gather(*list(ds._tasks)) # let the background warm finish
# Every slow cache was filled by the prewarm: contacts once, the channel probe once
# (idx 0 ok, idx 1 rejected), and the capacity probe once.
assert dev.contacts_calls == 1
assert dev.channel_calls == 2
assert dev.capacity_calls == 1
# A screen opening now is served from cache — no additional round-trips.
await ds.contacts()
await ds.channel_slots()
await ds.channel_capacity()
assert dev.contacts_calls == 1
assert dev.channel_calls == 2
assert dev.capacity_calls == 1
asyncio.run(run())