A contact's heard time came straight from the firmware's last_advert, which the advertising node stamps with its own clock. That makes it hearsay, and advert_time can only refuse a stamp from the future — a node whose RTC runs days behind reports a plausible-looking time that never catches up, so the lane sits at "4d ago" while we are receiving from it right now. Our own history, stamped when we actually received something, could only fill the gap when the device reported nothing at all. So merge instead of fill: take the later of the device's time and ours. A device time still wins whenever the firmware caught an advert we never recorded, and we only override it with proof of a later reception. A direct message now counts too. In this app's lexicon "heard" means received from, and a message is received from its sender — but a DM never touches last_advert and is stored as a message, not an observation, so a node we chat with daily could read stale and be swept by the purge ladder's quiet rungs while talking to us. Read from the messages table rather than recording an observation: a routed message's SNR belongs to its last relay, which is why heard_nodes excludes packet rows, and counting it would inflate a lane that means overheard traffic. The merge runs on every contacts fetch now, so both halves are GROUP BY queries that aggregate in SQLite and yield one row per node, instead of heard_nodes streaming all history into Python to build stat objects. Node detail takes the merged contact time, so it and the contact list can no longer disagree about one node. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
318 lines
12 KiB
Python
318 lines
12 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: "dict | None" = None,
|
|
messaged: "dict | None" = None,
|
|
) -> DeviceState:
|
|
"""A DeviceState over a fake ctx: device(), a silent logger, and our reception history.
|
|
|
|
``heard`` is node-id → when we last overheard it; ``messaged`` is peer prefix → when it
|
|
last sent us a direct message. Together they are the first-hand evidence the contacts
|
|
merge weighs against the device's advert times.
|
|
"""
|
|
|
|
async def device_getter():
|
|
return device
|
|
|
|
ctx = SimpleNamespace(
|
|
device=device_getter,
|
|
log=SimpleNamespace(debug=lambda *a, **k: None),
|
|
repo=SimpleNamespace(
|
|
last_heard_by_node=lambda: dict(heard or {}),
|
|
last_message_by_peer=lambda: dict(messaged or {}),
|
|
),
|
|
)
|
|
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_heard_time_takes_the_later_of_the_device_and_our_own_receptions() -> None:
|
|
"""A contact's heard time is the latest of the device's advert time and our evidence.
|
|
|
|
The device's ``last_advert`` is stamped by the *sender's* clock, so it is hearsay: it can
|
|
be absent (refused upstream by ``models.advert_time``), or plausible-looking yet days
|
|
stale because the node's RTC runs behind. Our own history is first-hand — stamped when we
|
|
received something — so the merge takes whichever is later. A device time still wins
|
|
whenever the firmware caught an advert we never recorded.
|
|
"""
|
|
dev = FakeDevice()
|
|
stale = datetime(2026, 7, 25, 9, 0, tzinfo=timezone.utc)
|
|
fresh = 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="Behind-Clock", public_key="bb" * 32, key_prefix="bb" * 6, last_seen=stale),
|
|
Contact(name="Ahead", public_key="cc" * 32, key_prefix="cc" * 6, last_seen=fresh),
|
|
Contact(name="Quiet", public_key="dd" * 32, key_prefix="dd" * 6),
|
|
]
|
|
ds = _devstate(dev, heard={"aa" * 6: fresh, "bb" * 6: fresh, "cc" * 6: stale})
|
|
|
|
async def run() -> None:
|
|
by_name = {c.name: c for c in await ds.contacts()}
|
|
assert by_name["Bogus-Clock"].last_seen == fresh # filled from our own history
|
|
assert by_name["Behind-Clock"].last_seen == fresh # our proof beats a stale stamp
|
|
assert by_name["Ahead"].last_seen == fresh # a newer device time still stands
|
|
assert by_name["Quiet"].last_seen is None # truly never heard stays never
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_a_direct_message_counts_as_hearing_its_sender() -> None:
|
|
"""An inbound DM updates the heard time — "heard" means received from.
|
|
|
|
A direct message never touches the firmware's ``last_advert`` and is stored as a message
|
|
rather than an observation, so a node we actively chat with could read days stale (and be
|
|
swept by the purge ladder's quiet rungs) while talking to us. The peer prefix the wire
|
|
addressed need not match the contact table's width, so either may be the shorter.
|
|
"""
|
|
dev = FakeDevice()
|
|
stale = datetime(2026, 7, 25, 9, 0, tzinfo=timezone.utc)
|
|
messaged_at = datetime(2026, 7, 29, 12, 0, tzinfo=timezone.utc)
|
|
dev.contact_rows = [
|
|
Contact(name="Chatty", public_key="ab" * 32, key_prefix="ab" * 6, last_seen=stale),
|
|
Contact(name="Silent", public_key="cd" * 32, key_prefix="cd" * 6, last_seen=stale),
|
|
]
|
|
# A six-hex peer against a twelve-hex contact id: the shorter one is the wire's.
|
|
ds = _devstate(dev, messaged={"ababab": messaged_at})
|
|
|
|
async def run() -> None:
|
|
by_name = {c.name: c for c in await ds.contacts()}
|
|
assert by_name["Chatty"].last_seen == messaged_at
|
|
assert by_name["Silent"].last_seen == stale # nobody else is credited
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_a_naive_stored_stamp_still_compares() -> None:
|
|
"""A tz-less row from an older build reads as the UTC the storage contract says it is.
|
|
|
|
Comparing a naive datetime against an aware one raises, which would take the whole
|
|
contacts fetch down rather than one lane.
|
|
"""
|
|
dev = FakeDevice()
|
|
stale = datetime(2026, 7, 25, 9, 0, tzinfo=timezone.utc)
|
|
naive = datetime(2026, 7, 29, 12, 0) # written before timestamps carried a zone
|
|
dev.contact_rows = [
|
|
Contact(name="Legacy", public_key="aa" * 32, key_prefix="aa" * 6, last_seen=stale)
|
|
]
|
|
ds = _devstate(dev, heard={"aa" * 6: naive})
|
|
|
|
async def run() -> None:
|
|
assert (await ds.contacts())[0].last_seen == naive.replace(tzinfo=timezone.utc)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_a_history_read_failure_leaves_the_contacts_untouched() -> None:
|
|
"""The merge is best-effort: a broken history read never blocks a contacts fetch."""
|
|
dev = FakeDevice()
|
|
device_says = datetime(2026, 7, 28, 9, 0, tzinfo=timezone.utc)
|
|
dev.contact_rows = [Contact(name="Solo", public_key="ee" * 32, key_prefix="ee" * 6,
|
|
last_seen=device_says)]
|
|
ds = _devstate(dev)
|
|
|
|
def boom() -> dict:
|
|
raise RuntimeError("history unavailable")
|
|
|
|
ds._ctx.repo.last_heard_by_node = boom # type: ignore[attr-defined]
|
|
|
|
async def run() -> None:
|
|
assert (await ds.contacts())[0].last_seen == device_says
|
|
|
|
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())
|