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>
224 lines
8.3 KiB
Python
224 lines
8.3 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The settings offer at connect time, run from start to end over a scripted device.
|
|
|
|
This scenario is the reason for the position rule (JP, 2026-08-08/09). A companion with no
|
|
GPS keeps its coordinates, which the user set by hand, only until it restarts. Then it
|
|
reports the 0/0 null island. SELF_INFO always has ``adv_lat`` and ``adv_lon`` as signed
|
|
microdegrees / 1e6, so "unset" arrives as exactly ``0.0``, never as a missing key (we
|
|
checked this against the meshcore reader).
|
|
|
|
The offer must write the remembered fix back at once. A write into an empty slot overwrites
|
|
nothing. The offer must never ask the user to weigh a position against no position. But a
|
|
device that reports a real, different fix still gets the honest prompt. A drift of a
|
|
setting that is not a position still prompts, together with 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 that the store of JP held when we diagnosed this flow. They are exact
|
|
#: microdegree quotients, so they go through the wire encoding and come back byte for byte.
|
|
_LAT, _LON = 45.535456, -73.708698
|
|
|
|
|
|
class _ScriptedDevice:
|
|
"""A companion that answers SELF_INFO from a dict and records the writes of coordinates."""
|
|
|
|
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
|
|
|
|
# A bare bridge does not support these optional reads for the snapshot. 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 part of AppContext that the offer reads, with 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
|
|
"""Run the offer one time. Record whether the prompt showed, and with what content."""
|
|
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:
|
|
"""The device reports the null island and the store has a fix: the offer restores it.
|
|
|
|
The offer does not show a 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 user was never asked
|
|
# The offer wrote both coordinates back. The second write has the value of the first
|
|
# (the coupled set_coords builds again from the changed snapshot), so the device ends
|
|
# with the full remembered fix.
|
|
assert device.coords_writes[-1] == (_LAT, _LON)
|
|
# The store still has the fix. The offer adopted nothing and forgot nothing.
|
|
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 a real, different position is a true conflict.
|
|
|
|
The offer prompts and does not 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) # the user dismisses the prompt
|
|
|
|
assert len(shown) == 1 and {d.key for d in shown[0]} == {"adv_lat", "adv_lon"}
|
|
assert device.coords_writes == [] # the dismissal 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:
|
|
"""The offer restores the position silently.
|
|
|
|
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 returned 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:
|
|
"""For a device with no remembered settings, the offer does one probe and finds nothing.
|
|
|
|
The offer writes nothing.
|
|
"""
|
|
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 == []
|
|
|
|
|
|
class _GpsDevice(_ScriptedDevice):
|
|
"""A companion with a GPS. It reports its ``gps`` switch as the firmware does."""
|
|
|
|
def __init__(self, *, gps: str, **kwargs) -> None: # noqa: ANN003
|
|
super().__init__(**kwargs)
|
|
self._gps = gps
|
|
|
|
async def get_custom_vars(self) -> dict[str, str]:
|
|
return {"gps": self._gps, "gps_interval": "0"}
|
|
|
|
|
|
def test_offer_leaves_a_position_the_gps_moved_alone(tmp_path: Path, monkeypatch) -> None:
|
|
"""If the GPS runs, a node in a new place is a reading. The offer does not prompt or write."""
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember(_PUB, "adv_lat", _LAT)
|
|
store.remember(_PUB, "adv_lon", _LON)
|
|
device = _GpsDevice(gps="1", adv_lat=46.813878, adv_lon=-71.207981)
|
|
|
|
shown = _run(_ctx(store, device), monkeypatch)
|
|
|
|
assert shown == [] and device.coords_writes == []
|
|
assert store.settings(_PUB) == {"adv_lat": _LAT, "adv_lon": _LON}
|
|
|
|
|
|
def test_offer_still_asks_about_a_moved_position_with_the_gps_off(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A GPS that does not run moved nothing, so a different position is a real conflict."""
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember(_PUB, "adv_lat", _LAT)
|
|
store.remember(_PUB, "adv_lon", _LON)
|
|
device = _GpsDevice(gps="0", adv_lat=46.813878, adv_lon=-71.207981)
|
|
|
|
shown = _run(_ctx(store, device), monkeypatch)
|
|
|
|
assert len(shown) == 1 and {d.key for d in shown[0]} == {"adv_lat", "adv_lon"}
|
|
|
|
|
|
def test_a_gps_with_no_fix_yet_still_gets_the_saved_position(tmp_path: Path, monkeypatch) -> None:
|
|
"""Before its first fix, the node reports none. The saved fix stays until a new fix comes."""
|
|
store = SettingsStore(tmp_path / "settings.json")
|
|
store.remember(_PUB, "adv_lat", _LAT)
|
|
store.remember(_PUB, "adv_lon", _LON)
|
|
device = _GpsDevice(gps="1", adv_lat=0.0, adv_lon=0.0)
|
|
|
|
shown = _run(_ctx(store, device), monkeypatch)
|
|
|
|
assert shown == [] and device.coords_writes[-1] == (_LAT, _LON)
|