`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>
158 lines
6.1 KiB
Python
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 == []
|