Files
MeshTerm/tests/test_settings_store.py
T
Jean-Pierre MartineauandClaude Opus 5 109fe7645d Move every command onto the seam, and give the plain face its own premise
Forty-nine leaf commands now state a Report instead of printing one, so --json
works on all of them at once rather than on the two that grew a branch for it.
The two hand-rolled JSON paths are gone: devices no longer builds a payload dict
of its own, and its empty scan exits 5 like the plain path always did rather than
0 — a rendering flag has no business moving the report.

The plain face changes premise along with it. It used to be a serialisation
format a person could squint at, and several of its rules existed only to make
splitting safe. Splitting is the machine face's job now, so:

- Names are bare. Alignment is the delimiter; the escaping under the quoting
  stays, because nothing else stops a name a node broadcast from ending its own
  record.
- Times are ages. "Heard recently?" is the question a contacts listing is opened
  to ask, and an ISO instant makes the reader do arithmetic to answer it.
  --absolute puts the instants back; an appointment, the device clock and a live
  capture's own column were never ages and still aren't.
- A route takes arrows and a path keeps its commas. The lexicon has always split
  these and the output never did, so a route line looked pasteable into --path,
  which it is not.
- The listings gain what was cut for parser hygiene: contacts gets the HASH
  --path takes and a LOCATION the model has always carried, preferences gets the
  DESCRIPTION already written in its registry, and the two live streams get
  pinned lanes — which is what let them have columns at all, and with them the
  last call site for the quoting.
- The four written pages wrap at 72 instead of printing 600-cell paragraphs. It
  is the one exception to "no wrapping", and the rule's own reason says why: a
  paragraph has no fields to land under the wrong headings.

info keeps its keys and gains a gloss on the four readings nobody can hold in
their head (93784 (1d 2h)); it is the one key/value block with no set behind it,
so there is no round trip to protect. config show is untouched, deliberately.

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

232 lines
9.3 KiB
Python

"""Tests for remembering device settings across sessions (``core.settings_store``).
Covers the durable per-device store, the drift detection that compares remembered values to a
device's live snapshot, and the ``restore``/``adopt`` reconcile actions the startup offer drives.
The reconcile tests run against the :class:`MockDevice` simulator, whose configuration resets to
firmware defaults each construction — standing in for the firmware-less radio bridge that forgets
its settings on restart.
"""
from __future__ import annotations
import io
import json
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.connection import make_device
from meshterm.core.device_config import build_snapshot
from meshterm.core.device_store import DeviceStore
from meshterm.core.settings_store import (
SettingsStore,
adopt,
restore,
settings_drift,
)
from meshterm.persistence.repository import Repository
from meshterm.tools.config import apply_ops
PUB_A = "aa" * 32
PUB_B = "bb" * 32
@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 / "cfg.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 settings survive a fresh store instance, kept per device."""
path = tmp_path / "settings.json"
store = SettingsStore(path)
store.remember(PUB_A, "name", "Ops-Node")
store.remember(PUB_A, "radio_freq", 915.0)
store.remember(PUB_B, "tx_power", 20)
reloaded = SettingsStore(path) # a new process reads the same file
assert reloaded.settings(PUB_A) == {"name": "Ops-Node", "radio_freq": 915.0}
assert reloaded.settings(PUB_B) == {"tx_power": 20} # a second device keeps its own set
def test_store_replace_forget_and_forget_all(tmp_path: Path) -> None:
"""Re-remembering a key replaces it; forget drops one; forget_all clears the device."""
store = SettingsStore(tmp_path / "settings.json")
store.remember(PUB_A, "name", "First")
store.remember(PUB_A, "name", "Second") # same key, new value
store.remember(PUB_A, "tx_power", 20)
assert store.settings(PUB_A) == {"name": "Second", "tx_power": 20}
store.forget(PUB_A, "name")
assert store.settings(PUB_A) == {"tx_power": 20}
store.forget_all(PUB_A)
assert store.settings(PUB_A) == {}
# The device drops out of the persisted file entirely once it has nothing remembered.
assert json.loads((tmp_path / "settings.json").read_text())["devices"] == {}
def test_store_normalises_device_key(tmp_path: Path) -> None:
"""A device key is matched case-insensitively and with an optional 0x prefix stripped."""
store = SettingsStore(tmp_path / "settings.json")
store.remember("AABB", "name", "Ops")
assert store.settings("0xaabb") == {"name": "Ops"}
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 / "settings.json"
path.write_text("not json at all", encoding="utf-8")
store = SettingsStore(path)
assert store.settings(PUB_A) == {}
store.remember(PUB_A, "name", "Ops") # recovers and persists
assert SettingsStore(path).settings(PUB_A) == {"name": "Ops"}
def test_store_drops_malformed_values(tmp_path: Path) -> None:
"""Non-scalar values (a container, a null) are skipped; scalar ones are kept."""
path = tmp_path / "settings.json"
path.write_text(
json.dumps(
{
"devices": {
PUB_A: {"name": "Good", "bad_list": [1, 2], "bad_null": None, "tx_power": 20}
}
}
),
encoding="utf-8",
)
assert SettingsStore(path).settings(PUB_A) == {"name": "Good", "tx_power": 20}
def test_store_ignores_non_scalar_remember(tmp_path: Path) -> None:
"""Remembering a non-scalar value is a no-op — only strings, numbers, and bools persist."""
store = SettingsStore(tmp_path / "settings.json")
store.remember(PUB_A, "name", ["not", "a", "scalar"])
assert store.settings(PUB_A) == {}
# --- drift -------------------------------------------------------------------
async def _mock_device():
device = make_device(mock=True, port=None)
await device.connect()
pubkey = (await device.get_self_info())["public_key"]
return device, pubkey
async def test_drift_reports_only_changed_settings(tmp_path: Path) -> None:
"""Drift lists remembered settings that differ from the device, in registry order."""
device, pubkey = await _mock_device()
store = SettingsStore(tmp_path / "settings.json")
store.remember(pubkey, "radio_freq", 915.0) # device default is 869.618 — drifts
store.remember(pubkey, "name", "MockCompanion") # equals the device default — no drift
snapshot = await build_snapshot(device)
drifted = settings_drift(store, pubkey, snapshot)
assert [d.key for d in drifted] == ["radio_freq"]
assert drifted[0].remembered == 915.0 and drifted[0].current == 869.618
async def test_drift_empty_when_nothing_remembered(tmp_path: Path) -> None:
"""A device with no remembered settings never shows drift (firmware radios pay nothing)."""
device, pubkey = await _mock_device()
store = SettingsStore(tmp_path / "settings.json")
snapshot = await build_snapshot(device)
assert settings_drift(store, pubkey, snapshot) == []
async def test_drift_skips_unknown_keys(tmp_path: Path) -> None:
"""A remembered key the registry no longer defines is ignored, not reported as drift."""
device, pubkey = await _mock_device()
store = SettingsStore(tmp_path / "settings.json")
store.remember(pubkey, "gone_from_registry", "whatever")
snapshot = await build_snapshot(device)
assert settings_drift(store, pubkey, snapshot) == []
# --- restore / adopt ---------------------------------------------------------
async def test_restore_writes_remembered_values_including_coupled(tmp_path: Path) -> None:
"""Restore replays saved values onto the device, coupled radio fields rebuilt together."""
device, pubkey = await _mock_device()
store = SettingsStore(tmp_path / "settings.json")
store.remember(pubkey, "name", "Ops-Node")
store.remember(pubkey, "radio_freq", 915.0)
store.remember(pubkey, "radio_sf", 12) # a second radio field: exercises the coupled apply
snapshot = await build_snapshot(device)
drifted = settings_drift(store, pubkey, snapshot)
written = await restore(store, device, snapshot, [d.key for d in drifted])
assert written == 3
after = await build_snapshot(device)
assert after["name"] == "Ops-Node"
assert after["radio_freq"] == 915.0
assert after["radio_sf"] == 12
assert after["radio_bw"] == 62.5 # untouched radio field preserved by the coupled rebuild
# Idempotent: with the device now matching, there is nothing left to restore.
assert settings_drift(store, pubkey, after) == []
async def test_adopt_updates_store_to_device_values(tmp_path: Path) -> None:
"""Adopt takes the device's current values as the new saved truth, clearing the drift."""
device, pubkey = await _mock_device()
store = SettingsStore(tmp_path / "settings.json")
store.remember(pubkey, "radio_freq", 915.0) # differs from the device's 869.618
snapshot = await build_snapshot(device)
drifted = settings_drift(store, pubkey, snapshot)
adopt(store, pubkey, snapshot, [d.key for d in drifted])
assert store.settings(pubkey)["radio_freq"] == 869.618 # now matches the device
assert settings_drift(store, pubkey, snapshot) == []
async def test_adopt_forgets_a_setting_the_device_no_longer_reports(tmp_path: Path) -> None:
"""Adopting a key the device reports no value for forgets it rather than storing a null."""
device, pubkey = await _mock_device()
store = SettingsStore(tmp_path / "settings.json")
store.remember(pubkey, "flood_scope", "#ops")
snapshot = await build_snapshot(device)
snapshot.pop("flood_scope", None) # simulate firmware that doesn't report this field
adopt(store, pubkey, snapshot, ["flood_scope"])
assert "flood_scope" not in store.settings(pubkey)
# --- write-through -----------------------------------------------------------
async def test_apply_setting_remembers_through_the_config_executor(ctx) -> None:
"""Every setting changed through the config executor is recorded under the device key."""
device = await ctx.device()
pubkey = (await device.get_self_info())["public_key"]
snapshot = await build_snapshot(device)
changes, _artifacts, _report = await apply_ops(
ctx, device, snapshot, [("set", "name", "Ops-Node"), ("set", "tx_power", "18")]
)
assert changes == 2
assert ctx.settings_store.settings(pubkey) == {"name": "Ops-Node", "tx_power": 18}