`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>
314 lines
13 KiB
Python
314 lines
13 KiB
Python
"""Tests for remembering contacts across sessions (``core.contact_store``).
|
|
|
|
Covers the durable per-device store, the ``RememberedContact`` <-> ``Contact`` round-trip, the
|
|
``merge_contacts`` union that surfaces remembered contacts a device is no longer reporting, and
|
|
the write-through/merge wired into :class:`~meshterm.services.device_state.DeviceState`. The
|
|
integration test runs against the :class:`MockDevice` simulator, emptying its contact table to
|
|
stand in for the firmware-less radio bridge that loses its contacts on restart.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from meshterm.context import AppContext
|
|
from meshterm.core.admin_store import AdminStore
|
|
from meshterm.core.config import Settings
|
|
from meshterm.core.contact_store import ContactStore, RememberedContact, merge_contacts
|
|
from meshterm.core.device_store import DeviceStore
|
|
from meshterm.core.models import (
|
|
NODE_TYPE_CHAT,
|
|
NODE_TYPE_REPEATER,
|
|
Contact,
|
|
is_direct_messageable,
|
|
utcnow,
|
|
)
|
|
from meshterm.persistence.repository import Repository
|
|
|
|
PUB_A = "aa" * 32
|
|
PUB_B = "bb" * 32
|
|
|
|
|
|
def _contact(name: str, key_byte: str, **kw) -> Contact:
|
|
"""A live Contact addressed by a full 32-byte key built from a repeated hex byte."""
|
|
pub = key_byte * 32
|
|
return Contact(name=name, public_key=pub, key_prefix=pub[:12], **kw)
|
|
|
|
|
|
@pytest.fixture()
|
|
def ctx(tmp_path: Path) -> AppContext:
|
|
"""A mock-backed application context with the plain (console) UI surface."""
|
|
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "contacts.db")
|
|
context = AppContext(
|
|
console=Console(file=io.StringIO()),
|
|
settings=settings,
|
|
repo=Repository(settings.db_path),
|
|
device_store=DeviceStore(tmp_path / "devices.json"),
|
|
admin_store=AdminStore(tmp_path / "admin.json"),
|
|
mock=True,
|
|
)
|
|
yield context
|
|
context.repo.close()
|
|
|
|
|
|
# --- the store ---------------------------------------------------------------
|
|
|
|
|
|
def test_store_round_trips_and_persists(tmp_path: Path) -> None:
|
|
"""Remembered contacts survive a fresh store instance, kept per device, sorted by name."""
|
|
path = tmp_path / "contacts.json"
|
|
store = ContactStore(path)
|
|
store.remember_all(
|
|
PUB_A, [_contact("Bob", "cc", node_type=NODE_TYPE_CHAT), _contact("Al", "dd")]
|
|
)
|
|
store.remember_all(PUB_B, [_contact("Carol", "ee")])
|
|
|
|
reloaded = ContactStore(path) # a new process reads the same file
|
|
a = reloaded.contacts(PUB_A)
|
|
assert [c.name for c in a] == ["Al", "Bob"] # name order
|
|
assert a[1].public_key == "cc" * 32 and a[1].node_type == NODE_TYPE_CHAT
|
|
assert [c.name for c in reloaded.contacts(PUB_B)] == ["Carol"] # separate device
|
|
|
|
|
|
def test_remember_all_upserts_and_never_drops_absent(tmp_path: Path) -> None:
|
|
"""A rename updates in place; a contact absent from a later read is kept, not removed."""
|
|
store = ContactStore(tmp_path / "contacts.json")
|
|
store.remember_all(PUB_A, [_contact("Alice", "cc"), _contact("Bob", "dd")])
|
|
# A later read the device only reports Alice under a new name — Bob must survive.
|
|
store.remember_all(PUB_A, [_contact("Alice-2", "cc")])
|
|
|
|
by_key = {c.public_key: c.name for c in store.contacts(PUB_A)}
|
|
assert by_key == {"cc" * 32: "Alice-2", "dd" * 32: "Bob"}
|
|
|
|
|
|
def test_remember_all_skips_keyless_contacts(tmp_path: Path) -> None:
|
|
"""A contact with no public key is unaddressable and is not remembered."""
|
|
store = ContactStore(tmp_path / "contacts.json")
|
|
store.remember_all(PUB_A, [Contact(name="Ghost", public_key="")])
|
|
assert store.contacts(PUB_A) == []
|
|
|
|
|
|
def test_store_normalises_keys(tmp_path: Path) -> None:
|
|
"""Both the device key and the contact key are matched case-insensitively, 0x stripped."""
|
|
store = ContactStore(tmp_path / "contacts.json")
|
|
store.remember_all("AABB", [Contact(name="Al", public_key="0xCCDD")])
|
|
remembered = store.contacts("0xaabb")
|
|
assert remembered and remembered[0].public_key == "ccdd"
|
|
|
|
|
|
def test_store_ignores_corrupt_file(tmp_path: Path) -> None:
|
|
"""A garbage file reads as empty rather than raising, and stays writable."""
|
|
path = tmp_path / "contacts.json"
|
|
path.write_text("not json at all", encoding="utf-8")
|
|
store = ContactStore(path)
|
|
assert store.contacts(PUB_A) == []
|
|
store.remember_all(PUB_A, [_contact("Al", "cc")]) # recovers and persists
|
|
assert [c.name for c in ContactStore(path).contacts(PUB_A)] == ["Al"]
|
|
|
|
|
|
def test_store_drops_malformed_entries(tmp_path: Path) -> None:
|
|
"""Entries missing a public key or name are skipped; good ones kept."""
|
|
path = tmp_path / "contacts.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"devices": {
|
|
PUB_A: [
|
|
{"public_key": "cc" * 32, "name": "Good"},
|
|
{"name": "Keyless"}, # no public_key
|
|
{"public_key": "dd" * 32}, # no name
|
|
]
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
assert [c.name for c in ContactStore(path).contacts(PUB_A)] == ["Good"]
|
|
|
|
|
|
def test_forget_drops_one_contact(tmp_path: Path) -> None:
|
|
"""Forgetting removes a single remembered contact and persists the change."""
|
|
store = ContactStore(tmp_path / "contacts.json")
|
|
store.remember_all(PUB_A, [_contact("Al", "cc"), _contact("Bob", "dd")])
|
|
store.forget(PUB_A, "cc" * 32)
|
|
assert [c.name for c in store.contacts(PUB_A)] == ["Bob"]
|
|
|
|
|
|
# --- RememberedContact <-> Contact -------------------------------------------
|
|
|
|
|
|
def test_remembered_contact_round_trip() -> None:
|
|
"""A live contact distils to remembered fields and rebuilds with them intact."""
|
|
heard = datetime(2026, 7, 20, 12, 0, 0, tzinfo=timezone.utc)
|
|
live = _contact("Alice", "cc", node_type=NODE_TYPE_CHAT, last_seen=heard, lat=45.5, lon=-73.6)
|
|
remembered = RememberedContact.from_contact(live)
|
|
assert remembered.public_key == "cc" * 32 and remembered.last_advert == int(heard.timestamp())
|
|
|
|
rebuilt = remembered.to_contact()
|
|
assert rebuilt.name == "Alice"
|
|
assert rebuilt.public_key == "cc" * 32 and rebuilt.key_prefix == "cc" * 6
|
|
assert rebuilt.last_seen == heard
|
|
assert rebuilt.node_type == NODE_TYPE_CHAT and rebuilt.lat == 45.5 and rebuilt.lon == -73.6
|
|
assert rebuilt.route_hops is None # a remembered contact floods until a path is relearned
|
|
|
|
|
|
def test_remembered_future_advert_rebuilds_as_never_heard() -> None:
|
|
"""A future-stamped epoch remembered before this guard existed rebuilds as unknown.
|
|
|
|
The stored epoch re-enters through ``models.advert_time``, so a poisoned value written
|
|
by an old session can't resurface a contact as "heard in the future" on a bridge.
|
|
"""
|
|
future = int(utcnow().timestamp()) + 7 * 86400
|
|
entry = RememberedContact(public_key="cc" * 32, name="Bogus-Clock", last_advert=future)
|
|
assert entry.to_contact().last_seen is None
|
|
|
|
|
|
# --- merge -------------------------------------------------------------------
|
|
|
|
|
|
def test_merge_unions_remembered_the_device_forgot(tmp_path: Path) -> None:
|
|
"""Merge appends remembered contacts the device forgot, without duplicating present ones."""
|
|
store = ContactStore(tmp_path / "contacts.json")
|
|
store.remember_all(PUB_A, [_contact("Al", "cc"), _contact("Bob", "dd"), _contact("Cy", "ee")])
|
|
|
|
live = [_contact("Al", "cc")] # the device only currently reports Al
|
|
merged = merge_contacts(store, PUB_A, live)
|
|
names = [c.name for c in merged]
|
|
assert names[0] == "Al" # live first, untouched
|
|
assert set(names) == {"Al", "Bob", "Cy"} # the two forgotten ones are unioned back in
|
|
assert names.count("Al") == 1 # present contact not duplicated
|
|
|
|
|
|
def test_merge_adds_nothing_when_device_reports_all(tmp_path: Path) -> None:
|
|
"""A device reporting its whole table (a firmware radio) gets no additions."""
|
|
store = ContactStore(tmp_path / "contacts.json")
|
|
live = [_contact("Al", "cc"), _contact("Bob", "dd")]
|
|
store.remember_all(PUB_A, live)
|
|
assert len(merge_contacts(store, PUB_A, live)) == 2
|
|
|
|
|
|
# --- DeviceState integration -------------------------------------------------
|
|
|
|
|
|
async def test_devstate_remembers_and_restores_forgotten_contacts(ctx) -> None:
|
|
"""Reading contacts remembers them; a device that then forgets its table still lists them."""
|
|
device = await ctx.device()
|
|
original = await ctx.devstate.contacts(force=True) # first read remembers the mock's table
|
|
original_keys = {c.public_key for c in original if c.public_key}
|
|
assert original_keys # the simulator ships a non-empty contact table
|
|
|
|
# The bridge "restarts": its RAM contact table is wiped. A fresh read finds nothing live...
|
|
device._contacts = []
|
|
restored = await ctx.devstate.contacts(force=True)
|
|
restored_keys = {c.public_key for c in restored}
|
|
|
|
# ...yet every contact we'd remembered is merged back in and stays messageable.
|
|
assert original_keys <= restored_keys
|
|
messageable = {c.public_key for c in original if is_direct_messageable(c.node_type)}
|
|
still = {c.public_key for c in restored if is_direct_messageable(c.node_type)}
|
|
assert messageable <= still
|
|
|
|
|
|
async def test_devstate_merge_keeps_a_repeater_a_repeater(ctx) -> None:
|
|
"""A remembered repeater rebuilds with its type, so the DM filter still excludes it."""
|
|
device = await ctx.device()
|
|
device._contacts = [
|
|
Contact(
|
|
name="Big-Repeater",
|
|
public_key="ab" * 32,
|
|
key_prefix="ab" * 6,
|
|
node_type=NODE_TYPE_REPEATER,
|
|
),
|
|
]
|
|
await ctx.devstate.contacts(force=True) # remember it
|
|
device._contacts = [] # forget it
|
|
restored = await ctx.devstate.contacts(force=True)
|
|
repeater = next(c for c in restored if c.public_key == "ab" * 32)
|
|
assert repeater.node_type == NODE_TYPE_REPEATER
|
|
assert not is_direct_messageable(repeater.node_type)
|
|
|
|
|
|
def test_an_archived_contact_is_remembered_but_kept_out_of_the_merge(tmp_path) -> None: # noqa: ANN001
|
|
"""Archiving frees a device slot without losing the contact — and without undoing itself.
|
|
|
|
The sweep's whole shape (see :mod:`meshterm.ui.purge_screen`): the contact is deleted
|
|
from the *device*, which is the scarce resource, and kept here in full. The subtlety is
|
|
the merge — this store's normal job is to union remembered contacts back into any list
|
|
the device isn't reporting, which would put an archived contact straight back and make
|
|
the sweep look like it did nothing.
|
|
"""
|
|
from meshterm.core.models import Contact
|
|
|
|
store = ContactStore(tmp_path / "contacts.json")
|
|
alice = Contact(name="Alice", public_key="aa" * 32, key_prefix="aa" * 6)
|
|
bob = Contact(name="Bob", public_key="bb" * 32, key_prefix="bb" * 6)
|
|
store.remember_all(PUB_A, [alice, bob])
|
|
|
|
store.archive(PUB_A, alice, when=1_700_000_000)
|
|
|
|
# Remembered in full — the public key that makes a restore possible is still here.
|
|
archived = store.archived(PUB_A)
|
|
assert [c.name for c in archived] == ["Alice"]
|
|
assert archived[0].public_key == "aa" * 32
|
|
assert archived[0].archived_at == 1_700_000_000
|
|
|
|
# …but withheld from the union, so the device's slot really is free.
|
|
merged = merge_contacts(store, PUB_A, [])
|
|
assert [c.name for c in merged] == ["Bob"]
|
|
|
|
# Survives a reload: the mark is persisted, not just held in memory.
|
|
reloaded = ContactStore(tmp_path / "contacts.json")
|
|
assert [c.name for c in reloaded.archived(PUB_A)] == ["Alice"]
|
|
assert [c.name for c in merge_contacts(reloaded, PUB_A, [])] == ["Bob"]
|
|
|
|
|
|
def test_restoring_clears_the_mark_and_the_contact_merges_again(tmp_path) -> None: # noqa: ANN001
|
|
"""A restore is the exact inverse — the contact rejoins every list it was withheld from."""
|
|
from meshterm.core.models import Contact
|
|
|
|
store = ContactStore(tmp_path / "contacts.json")
|
|
alice = Contact(name="Alice", public_key="aa" * 32, key_prefix="aa" * 6)
|
|
store.archive(PUB_A, alice, when=1_700_000_000)
|
|
assert merge_contacts(store, PUB_A, []) == []
|
|
|
|
was = store.restore(PUB_A, "aa" * 32)
|
|
assert was is not None and was.name == "Alice"
|
|
assert store.archived(PUB_A) == []
|
|
assert [c.name for c in merge_contacts(store, PUB_A, [])] == ["Alice"]
|
|
|
|
# Restoring one that isn't archived is a no-op, not an error.
|
|
assert store.restore(PUB_A, "aa" * 32) is None
|
|
assert store.restore(PUB_A, "ff" * 32) is None
|
|
|
|
|
|
def test_archiving_a_contact_the_store_never_saw_still_remembers_it(tmp_path) -> None: # noqa: ANN001
|
|
"""The usual case: a firmware radio's contact is live-only until the sweep takes it.
|
|
|
|
Archiving upserts first, so the record that makes a restore possible exists precisely
|
|
because the sweep created it — there is no earlier read to depend on.
|
|
"""
|
|
from meshterm.core.models import Contact
|
|
|
|
store = ContactStore(tmp_path / "contacts.json")
|
|
unseen = Contact(name="Carol", public_key="cc" * 32, key_prefix="cc" * 6, lat=45.5, lon=-73.6)
|
|
store.archive(PUB_A, unseen, when=1_700_000_000)
|
|
|
|
archived = store.archived(PUB_A)
|
|
assert [c.name for c in archived] == ["Carol"]
|
|
assert (archived[0].lat, archived[0].lon) == (45.5, -73.6)
|
|
|
|
|
|
def test_a_keyless_contact_cannot_be_archived(tmp_path) -> None: # noqa: ANN001
|
|
"""Nothing to restore it by, so archiving it would be a silent deletion."""
|
|
from meshterm.core.models import Contact
|
|
|
|
store = ContactStore(tmp_path / "contacts.json")
|
|
store.archive(PUB_A, Contact(name="Ghost"), when=1_700_000_000)
|
|
assert store.archived(PUB_A) == []
|