Files
MeshTerm/tests/test_settings_store.py
T
Jean-Pierre MartineauandClaude Opus 5 6924ff7f45 The presets are MeshCore's, not ours
Every one of the region presets named a modulation no mesh runs any more, and
two of them named a frequency nobody was ever on.

MeshTerm's list was three regions on 250 kHz / SF11 — the shape MeshCore
shipped with — plus two trade-off rows built on the EU frequency. Through 2025
most regions left that modulation for a narrow 62.5 kHz one, so the EU row is
what MeshCore now calls "EU/UK (Deprecated)", the US row had the right
frequency with the wrong modem (910.525 is SF7/BW62.5/CR5), and "AU / NZ 915"
at 915.525 was not a preset at all: Australia is 915.800 wide, 916.575 narrow,
923.125 in SA/WA and QLD, and New Zealand is 917.375. Four parameters have to
match for two nodes to hear each other, so a stale table is a radio staged onto
an empty channel.

The list is now MeshCore's own suggested settings, mirrored verbatim — names,
order and values — from what its apps, the web flasher and config.meshcore.dev
all read at api.meshcore.nz/api/v1/config. Twenty-three regions, including the
two MeshCore still lists under its own "(Deprecated)" names, because nodes that
never re-tuned are on them and picking one is how you go and listen. The names
are spelled as MeshCore spells them so the row can be read against the phone
app.

Four regions also name a path-hash size, which the preset now stages alongside
the radio fields as the firmware's mode (size - 1), exactly as MeshCore's own
config GUI converts it. The rows moved to the two-lane menu shape — name, then
the parameters in MeshCore's print order — which fits 53 columns where the old
one-line title ran to 85, and the list opens on the preset the radio is already
tuned to, staged values included, so picking a neighbour is a comparison rather
than a guess.

The trade-off pair is gone with them. They were ours rather than MeshCore's,
they pinned a deprecated EU frequency onto whoever picked them, and the wide /
mid / narrow choice they were reaching for is one the region list now makes
properly per region.

The simulator boots on the firmware's build defaults too (869.618, BW 62.5,
SF8, CR5 per MeshCore's platformio.ini), rather than on the settings the EU
mesh left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:17:57 -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, _ = 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}