Files
MeshTerm/tests/test_settings_offer.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
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>
2026-09-13 13:09:18 -04:00

159 lines
6.1 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The connect-time settings offer, driven end to end over a scripted device.
The scenario that motivated the position rule (JP, 2026-08-08/09): a GPS-less companion
holds its manually-set coordinates only until it restarts, then reports the 0/0 null
island — SELF_INFO always carries ``adv_lat``/``adv_lon`` as signed microdegrees / 1e6,
so "unset" arrives as exactly ``0.0``, never as a missing key (verified against the
meshcore reader). The offer must write the remembered fix straight back (filling an
empty slot overwrites nothing) and never ask the operator to weigh a position against
no position — while a device reporting a *real*, different fix still gets the honest
prompt, and non-position drift keeps prompting alongside the silent restore.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
import meshterm.ui.settings_offer as offer_mod
from meshterm.core.settings_store import SettingsStore
from meshterm.ui.settings_offer import offer_remembered_settings
_PUB = "e4a392456ead6f500725758729eb9f9decccf0ccd22d1d7723131e973eeb12a4"
#: The coordinates JP's store really held when this flow was diagnosed — exact
#: microdegree quotients, so they round-trip the wire encoding byte-for-byte.
_LAT, _LON = 45.535456, -73.708698
class _ScriptedDevice:
"""A companion that answers SELF_INFO from a dict and records coordinate writes."""
def __init__(self, *, adv_lat: float, adv_lon: float, name: str = "Homestead-Pico") -> None:
self._info = {
"public_key": _PUB,
"name": name,
"adv_lat": adv_lat,
"adv_lon": adv_lon,
}
self.coords_writes: list[tuple[float, float]] = []
async def get_self_info(self) -> dict:
return dict(self._info)
async def set_coords(self, lat: float, lon: float) -> None:
self.coords_writes.append((lat, lon))
self._info["adv_lat"], self._info["adv_lon"] = lat, lon
async def set_name(self, name: str) -> None:
self._info["name"] = name
# The optional snapshot reads a bare bridge doesn't support — build_snapshot skips them.
async def get_tuning(self) -> dict:
raise RuntimeError("unsupported")
async def get_path_hash_mode(self) -> int:
raise RuntimeError("unsupported")
async def get_autoadd_config(self) -> int:
raise RuntimeError("unsupported")
async def get_default_flood_scope(self) -> dict:
raise RuntimeError("unsupported")
def _ctx(store: SettingsStore, device: _ScriptedDevice) -> SimpleNamespace:
"""The slice of AppContext the offer reads, over the scripted device."""
import logging
async def dev() -> _ScriptedDevice:
return device
return SimpleNamespace(
settings_store=store,
is_connected=True,
device=dev,
devstate=SimpleNamespace(invalidate_config=lambda: None),
log=logging.getLogger("test-settings-offer"),
)
def _run(ctx, monkeypatch, *, choice=None): # noqa: ANN001, ANN202
"""Drive the offer once, capturing whether (and with what) the prompt was shown."""
shown: list[list] = []
async def fake_prompt(_ctx, drifted): # noqa: ANN001, ANN202
shown.append(list(drifted))
return choice
monkeypatch.setattr(offer_mod, "_prompt", fake_prompt)
asyncio.run(offer_remembered_settings(ctx))
return shown
def test_offer_restores_a_position_the_device_lost_without_asking(
tmp_path: Path,
monkeypatch,
) -> None:
"""Device reports the null island, store remembers a fix → silent restore, no prompt."""
store = SettingsStore(tmp_path / "settings.json")
store.remember(_PUB, "adv_lat", _LAT)
store.remember(_PUB, "adv_lon", _LON)
device = _ScriptedDevice(adv_lat=0.0, adv_lon=0.0)
shown = _run(_ctx(store, device), monkeypatch)
assert shown == [] # the operator was never bothered
# Both coordinates were written back; the second write carries the first's value
# (the coupled set_coords rebuilds from the updated snapshot), so the device ends
# on the full remembered fix.
assert device.coords_writes[-1] == (_LAT, _LON)
# The store still remembers the fix — nothing was adopted or forgotten.
assert store.settings(_PUB) == {"adv_lat": _LAT, "adv_lon": _LON}
def test_offer_still_asks_when_the_device_reports_a_real_conflicting_fix(
tmp_path: Path,
monkeypatch,
) -> None:
"""A device with an actual different position is a true conflict: prompt, don't write."""
store = SettingsStore(tmp_path / "settings.json")
store.remember(_PUB, "adv_lat", _LAT)
store.remember(_PUB, "adv_lon", _LON)
device = _ScriptedDevice(adv_lat=45.533451, adv_lon=-73.710235)
shown = _run(_ctx(store, device), monkeypatch, choice=None) # operator dismisses
assert len(shown) == 1 and {d.key for d in shown[0]} == {"adv_lat", "adv_lon"}
assert device.coords_writes == [] # dismissing wrote nothing
assert store.settings(_PUB) == {"adv_lat": _LAT, "adv_lon": _LON}
def test_offer_splits_a_mixed_drift_between_silent_position_and_prompted_rest(
tmp_path: Path,
monkeypatch,
) -> None:
"""Position restores silently while the other drifted settings still reach the prompt."""
store = SettingsStore(tmp_path / "settings.json")
store.remember(_PUB, "adv_lat", _LAT)
store.remember(_PUB, "adv_lon", _LON)
store.remember(_PUB, "name", "Homestead-Pico")
device = _ScriptedDevice(adv_lat=0.0, adv_lon=0.0, name="MeshCore-abcd")
shown = _run(_ctx(store, device), monkeypatch, choice=None)
assert device.coords_writes[-1] == (_LAT, _LON) # the fix came back silently
assert len(shown) == 1 and [d.key for d in shown[0]] == ["name"] # only the real conflict
def test_offer_stays_quiet_when_nothing_is_remembered(tmp_path: Path, monkeypatch) -> None:
"""A device with no remembered settings pays one probe and sees nothing — no writes."""
store = SettingsStore(tmp_path / "settings.json")
device = _ScriptedDevice(adv_lat=0.0, adv_lon=0.0)
shown = _run(_ctx(store, device), monkeypatch)
assert shown == [] and device.coords_writes == []