Files
MeshTerm/tests/test_contact_store.py
T
Jean-Pierre MartineauandClaude Opus 5 99cd616ae5 The purge keeps the contacts worth a slot, and archives the rest
A device's contact table is finite, and a busy mesh fills it with whatever
adverts arrived — mostly nodes heard once, in passing, four hops out — until
there is no room left to discover anyone new. The age ladder could only ask
how long ago; it had nothing to say about which of two equally quiet contacts
was the one you would miss.

So contacts are now rated. Six weighted terms, each normalised and summed:
direct correspondence heaviest at 30, then how lately heard (25), how often
(15), channel posting (12), hops (10) and distance (8). Additive rather than
multiplicative, because a product lets one zero annihilate a contact that is
strong everywhere else — a node advertising no position would have scored
nothing however much you talk to it.

The rule the whole thing rests on is that an unknown scores *neutral*. Half a
real mesh advertises no location, plenty of nodes are never overheard as a
relayed frame so carry no hop count, and a channel poster whose name two
contacts share can be attributed to neither — the wire gives a channel frame
no sender key at all. A term that cannot be computed takes the population
median of the contacts that could, so the decision falls to the axes that are
known. Scoring those as zero would have swept by how much metadata a node
happens to broadcast. It is also why the two least reliable terms carry the
two smallest weights, and why distance calibrates against this mesh's own
median spread rather than a constant: 50 km is far downtown and unremarkable
in a valley.

A newcomer gets 25 points decaying to nothing over a fortnight, because every
other term rewards accumulated evidence and a node first heard this morning is
otherwise arithmetically identical to one quiet for a year. Four things are
never candidates at all: a watched node, anyone you have sent a message to, a
repeater whose admin login is stored, and a contact MeshTerm has never heard —
that last one predates this history, and sweeping it would be purging by how
long the app has been running. They still rank, since a percentile that
dropped them would not be a rank against your contacts; they just consume no
kept slot, so starring a node never deepens the next sweep.

Only the percentile is ever displayed. A raw 47.3 means nothing without the
distribution it came from and would need a legend the moment the weights were
retuned.

The victims are archived, not deleted: off the device, which is the scarce
resource, and kept here with a stamp. Everything else was already independent
of the contact row — reception history, map position, transcripts — and the
store keeps the full key, so a restore is one write, which the chat screen
already performs on demand when a send is rejected for an unknown recipient.
merge_contacts learns to withhold them, or the union would put them straight
back and the sweep would look like it did nothing.

The old age ladder survives as the second section of the one picker, because
"everything I haven't heard in a year" is a plain predictable operation a
ranking cannot express. Both routes converge on the same preview — weakest
first, with each row's percentile and the two or three facts that put it
there — so an age rung reaching into contacts that rank perfectly well says so
before the typed confirm rather than after.

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

310 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) == []