Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
233 lines
9.3 KiB
Python
233 lines
9.3 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""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}
|