Files
MeshTerm/tests/test_device_state.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

405 lines
15 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 _CONTACTS_TTL_S, DeviceState
class FakeDevice:
"""A stand-in device that counts how often each cached read hits the wire."""
def __init__(self) -> None:
"""Start with every call count at zero and no fixed contact table."""
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:
"""The contact list, counting the call.
Either the fixed table a test installed, or a single contact whose name carries
the call number — so a second read is visible in what comes back.
"""
self.contacts_calls += 1
if self.contact_rows is not None:
return list(self.contact_rows)
# a fresh identity per fetch, so a re-read is visible
return [Contact(name=f"contact-{self.contacts_calls}")]
async def get_self_info(self) -> dict:
"""A fixed self-description, counting the call."""
self.self_info_calls += 1
return {"name": "node", "tx_power": 20}
async def get_path_hash_mode(self) -> int:
"""A fixed path hash mode, counting the call."""
self.mode_calls += 1
return 2
async def get_channel(self, idx: int):
"""One configured slot at index 0, then a raised error, counting each call.
Refusing the next index is how the firmware answers a slot it does not have,
and it is what stops the caller walking past the last one.
"""
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:
"""The slot count, counting the call.
A fixed hardware constant, so the cache must read it exactly once per 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_every_cache_off_the_read_path() -> None:
"""prewarm() warms all five facts, so the first screen open hits no wire at all."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
ds.prewarm()
await asyncio.gather(*list(ds._tasks)) # let the background warm finish
# Every cache was filled by the prewarm: self-info and the routing mode once each,
# contacts once, the channel probe once (idx 0 ok, idx 1 rejected), capacity once.
assert dev.self_info_calls == 1
assert dev.mode_calls == 1
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. The
# path-hash mode belongs in that list: Contacts and Trace both read it to size the
# key-hash highlight, and nothing else in the session warms it.
await ds.contacts()
await ds.self_info()
await ds.path_hash_mode()
await ds.channel_slots()
await ds.channel_capacity()
assert dev.self_info_calls == 1
assert dev.mode_calls == 1
assert dev.contacts_calls == 1
assert dev.channel_calls == 2
assert dev.capacity_calls == 1
asyncio.run(run())
def test_prewarm_reads_the_cheap_facts_before_the_slot_probes() -> None:
"""Order is the point: the reads share one link, and the probes are the slow ones.
Both slot reads walk the firmware's slot table an index at a time and are measured in
seconds; the facts every list screen needs are single round-trips. Warming them first
is what stops a Contacts open a second after connect from queueing behind a slot walk.
"""
dev = FakeDevice()
ds = _devstate(dev)
order: list[str] = []
for name in (
"get_self_info",
"get_path_hash_mode",
"get_contacts",
"get_channel",
"channel_capacity",
):
inner = getattr(dev, name)
async def traced(*a, _name=name, _inner=inner, **k): # noqa: ANN001, ANN202
if _name != "get_channel" or "get_channel" not in order:
order.append(_name)
return await _inner(*a, **k)
setattr(dev, name, traced)
async def run() -> None:
ds.prewarm()
await asyncio.gather(*list(ds._tasks))
asyncio.run(run())
assert order == [
"get_self_info",
"get_path_hash_mode",
"get_contacts",
"get_channel",
"channel_capacity",
]
def test_a_concurrent_path_hash_read_joins_the_warm_instead_of_racing_it() -> None:
"""The prewarm now reads the mode, so a screen opening beside it must not re-read."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
await asyncio.gather(ds.path_hash_mode(), ds.path_hash_mode(), ds.path_hash_mode())
assert dev.mode_calls == 1
asyncio.run(run())