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>
249 lines
9.6 KiB
Python
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}
|