Files
MeshTerm/tests/test_settings_offer.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

158 lines
6.1 KiB
Python

"""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 == []