`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>
405 lines
15 KiB
Python
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())
|