Files
MeshTerm/tests/test_settings_store.py
Jean-Pierre MartineauandClaude Opus 5.5 11b5274b31 Docstrings and comments of the tests are in ASD-STE100
Each test docstring states the behaviour that the test proves, in the
simple present. No assertion message, test id, or other string literal
changed. Four statements now agree with the code: the Trace highlight
stays on the last row, a menu count, and two others.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 21:36:50 -04:00

249 lines
9.6 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for the memory of device settings across sessions (``core.settings_store``).
The tests cover three parts. The first is the durable store for each device. The second is
the drift detection, which compares the remembered values with the live snapshot of a device.
The third is the ``restore`` and ``adopt`` reconcile actions that the offer at startup runs.
The reconcile tests run against the :class:`MockDevice` simulator. Its configuration resets
to the firmware defaults at each construction. This is like the radio bridge with no firmware,
which forgets its settings at a 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:
"""An application context that uses the mock, 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 stay in a new store instance.
The store keeps them for each 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 has its own set
def test_store_replace_forget_and_forget_all(tmp_path: Path) -> None:
"""A new remember of a key replaces it. forget removes one key. forget_all clears the device."""
store = SettingsStore(tmp_path / "settings.json")
store.remember(PUB_A, "name", "First")
store.remember(PUB_A, "name", "Second") # the same key, a 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) == {}
# When the device has nothing remembered, the stored file does not have the device.
assert json.loads((tmp_path / "settings.json").read_text())["devices"] == {}
def test_store_normalises_device_key(tmp_path: Path) -> None:
"""A device key matches in any case, and the store removes an optional 0x prefix."""
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 file with invalid data reads as empty instead of raising an error, 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") # the store recovers and stores the value
assert SettingsStore(path).settings(PUB_A) == {"name": "Ops"}
def test_store_drops_malformed_values(tmp_path: Path) -> None:
"""The store skips non-scalar values (a container, a null). It keeps scalar values."""
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:
"""A remember of a non-scalar value does nothing.
The store keeps only strings, numbers, and bools.
"""
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 the remembered settings that are different from the device, in registry order."""
device, pubkey = await _mock_device()
store = SettingsStore(tmp_path / "settings.json")
store.remember(pubkey, "radio_freq", 915.0) # the device default is 869.618: drift
store.remember(pubkey, "name", "MockCompanion") # the same as 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 have no cost."""
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:
"""The drift ignores a remembered key that the registry does not define.
It does not report the key.
"""
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 writes the saved values to the device.
It builds the coupled radio fields 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: this tests 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 # the coupled rebuild keeps a radio field that was not changed
# Idempotent: the device now matches, so nothing is 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 current values of the device as the new saved values.
This clears the drift.
"""
device, pubkey = await _mock_device()
store = SettingsStore(tmp_path / "settings.json")
store.remember(pubkey, "radio_freq", 915.0) # different from the 869.618 of the device
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 the same as the device
assert settings_drift(store, pubkey, snapshot) == []
async def test_adopt_forgets_a_setting_the_device_no_longer_reports(tmp_path: Path) -> None:
"""Adopt forgets a key that the device reports with no value. It does not store 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 does not 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:
"""The store records each setting that the config executor changes, 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}