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>
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>
`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>
The last 122 `D205` sites, which takes `ruff check .` to zero.
Same shape as the package pass and just as additive: these were a paragraph
with no summary, so each one gains a plain sentence saying what is being
asserted, and every word that was there moves down into the description. The
dense ones benefit most — `test_pathline`'s 23, where "A seam is one cell, not
two" now stands alone and the chevron mechanics explain it underneath, and
`test_tui`'s wide-glyph repaint test, whose seven lines on prompt_toolkit's
relative cursor now sit under the one line that says what the test pins.
Four module docstrings (`test_advert`, `test_gallery`, `test_platforms`,
`test_repeater_admin`) opened with "X tests: a, b, and c" and now name the
subject on the first line with the list below it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 46 missing docstrings, written rather than generated.
Most of the test ones already carried the explanation — as a comment sitting on
the first line of the body. That prose is the docstring; it was only in the
wrong place, so the config editor's PIN and lane tests keep their own words and
lose the duplicate comment. The rest are new: the two `repo` fixtures, the
counting `FakeDevice` in `test_device_state` (each method now says what it
answers *and* that it counts the call), the PicoCalc chrome and 16-slot theme
tests, and three one-offs.
In the package: `PathLine` and `FastRenderer`'s constructors point at the class
docstring that already documents their arguments rather than repeating it,
`SelectItem.__post_init__` and `courier_screen`'s opener say what they cache and
why, and `emoji_width.create_content` explains the memo it wraps the line lookup
in. `flash.py` and `uart1-mux.py` get theirs too, and `tests/__init__.py`
finally says what the package is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`E501` across 69 lines, wrapped rather than exempted.
The prose ones — module docstrings in `menu`, `node_detail_screen`,
`pathline`, `pathgraph`, `walk_screen`, `trace_runner` and `test_about` — are
reflowed as paragraphs, so the block keeps its shape instead of gaining one
short orphan line. Nothing they say changed.
The code ones mostly gained a line break inside a call. Where a break would
have read worse than a name, the sub-expression got one instead: `reach` in
`topology`'s quality clamp, `plain_cells` in `pathline`, `numbering` in the
trace list, `listed` in `flash.py`'s refusal, and a small `shape`/`is_strip`
helper in two tests that were comparing or matching the same shape twice on one
line. Four docstring summaries lost a word they were not using.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`B905` over 56 sites, and the answer differs by idiom rather than by file.
21 were the sliding window `zip(xs, xs[1:])` — ragged by exactly one, on
purpose, so `strict=` is the wrong question entirely. Those become
`itertools.pairwise`, which is what they always meant and needs no flag: the
walk's SNR stops, the heat ladder, the route chains in `topology`/`records`,
every polyline in `mapcanvas`, and the graph's edge sets in `pathgraph`.
The other 35 pair sequences that must line up, so they now say `strict=True`
and a mismatch raises instead of silently truncating the longer side. Running
them under that assertion is what sorted the two groups: the first pass put
`strict=True` everywhere and 208 tests failed, all of them a sliding window.
One goes the other way. `mvt._decode_feature` reads tags off a network tile,
where an odd-length list means the tile is malformed — and this decoder answers
that with a shrug everywhere else (`if not geom_ints: return None`, bounds-
checked key and value indices). It keeps `strict=False`, now with a comment
saying why, so a bad basemap tile still can't crash the map.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ruff check --fix` over the whole tree, ahead of putting the linter in CI:
1977 mechanical fixes, overwhelmingly `Optional[X]` → `X | None` (1186 in the
package alone) and the quoted annotations that `from __future__ import
annotations` already made unnecessary, plus import sorting and unused imports.
Nothing here changes behaviour — the suite is 1885 green before and after.
Four findings were real rather than stylistic and are fixed by hand: a dead
`cells` range in `MapCanvas`, a dead slot dict in the heat-ladder test, a
`ProgressScreen` task handle nobody read, and a tab loop binding a `label` it
never used. The two `F821`s were quoted forward references to names imported
inside their function bodies — now declared under `TYPE_CHECKING`, which keeps
`test_performance`'s local import (it guards the startup import graph) while
letting the linter resolve the name.
412 findings remain and are deliberately untouched: they need a decision about
the rule, not an edit to the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every node in the suite was named after hardware on JP's desk: YUL-Cartierville
and YUL-Poly are repeaters he runs, two more were the BLE
companions, another his own handle, and a long tail of YUL-Far / YUL-Me / bare
YUL besides. A stranger reading the tests learned his callsign, his city and his
gear before learning what the tests check.
They join the family the suite had already invented for exactly this — Alice,
Lounge, Yagi-Repeater, hub-1: Hilltop-Repeater, Lakeside, Waymarker, Homestead,
and Hub for the 3d-keyed node. Hub is not a new name; test_device_config already
called that contact Hub, so the suite was carrying two names for one fixture.
Replacements are the same length as what they replace, so every width budget,
truncation assertion and gallery line is unchanged. The two map tests that type
a literal query picked `yu`/`yul` to match the old name; they type `hi`/`hil`
now, same length, same single match.
Left alone on purpose: the About page and the donate link, where the handle and
the repeaters are the point, the hand-drawn splash art, and the XIAO README
reporting what JP's own radio advertises.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fan is one route read against its alternatives, and it only says which route
that is if everything not on it dims. The unused lines already drew grey, but
every marker on them kept its node-type colour and every label its name hue —
so the loudest things in the picture were the nodes the selection had just
ruled out (JP, 2026-09-04).
The widget owns the rule now: where one layer is emphasised over the rest,
`render_path_graph` wraps the caller's marker and label colour callbacks and
draws every node outside the emphasised path in one dark grey. Membership is
tested in the graph's own id space — after the prefix coalesce, the identical-
path collapse and the revisit split — so a route reaching a relay by a short
hash still lights the wide marker its hop folds into. The endpoints are on
every path and never dim; the glyphs keep their shapes, only the ink recedes;
and a picture with nothing emphasised (a lone path, or a fan of equals) fades
nothing at all, so the packet viewer and the Trophy case are untouched.
Node detail had been doing half of this by hand — labels only, its own copy of
the coalesce — and drops that wrapper for the shared rule. Message paths gets
the whole of it for the first time, on both platforms: the grey lands on the
console's dark-grey slot through the same quantizer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merging a repeated hop into one marker is right across paths and wrong
within one. A via chain that names the same hop twice closes a cycle, and
the left-to-right flow has nowhere to seat it: the balanced-x relaxation
iterates a cyclic edge set, never reaches its fixed point, and returns
wherever the sweep budget stopped. The loop's relays then land in one
column — markers piled a cell apart, labels colliding or dropped, the back
edge falling as a bare vertical — while the hops outside it are crushed
against the ends. Worse, the residue depends on set iteration order, so
the pile's column swung with the hash seed: the very frame-to-frame jump
the first-appearance node order exists to prevent.
render_path_graph grows allow_duplicate_nodes, which hands each revisit
its own marker (occurrence-qualified ids the caller's callbacks never see,
so both markers draw as the same node). The walk is acyclic again, spreads
evenly, and reads in the order it was heard. Relays shared *between* paths
still merge — that fan is what the widget is for.
Opt-in, because the two situations want opposite things. The Trophy case
scores a path we composed, so collapsing a revisit is deliberate there —
re-walking a node must not make the shape look bigger than the ground it
covered. An overheard chain has no such duty: nothing in it is ours to
inflate, and a repeat is evidence.
Evidence we cannot read, though — hops are addressed by a one-byte hash,
so a repeat is as likely two colliding nodes as a genuine loop. The packet
card and the Message paths dialog now say exactly that under the via row
and the graph, naming the repeated hops through the shared path widget.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A route that skips a relay another route rides (the A-B-C-D spine
beside the shorter A-C-D) drew its skip edge as a level run straight
through the skipped marker's cell — and under emphasis the subset's
white repainted the spine's own run, so the skipped relay read as
selected (Furthur -> YUL-Cartierville lighting C14903).
_bypass_vias now bends any edge that would run level through a marker
strictly between its ends — by construction a node that edge skips —
around it, through an unmarked via point in the skipped node's own
column on the innermost genuinely free lane: no seated marker, no other
route's level run through that cell, and a lane outside the band only
while _band_rows still fits max_rows (the same budget the detour fold
answers to). No room anywhere keeps the honest level pass-over. _route
threads the vias with its usual level-or-shift grammar, so the bypass
eases out, sits level dead over the marker it clears, and eases back.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The detour fold ran unconditionally, so a route heard as a sibling plus
an inserted relay always dropped that relay onto the sibling's lane —
and the sibling's straight run drew right over a marker it never visits
(the real YUL-Poly fan collapsed to one flat line through three routes,
with label muting as the only cue). Now the whole fan is measured
against the row budget before the shape is committed: while the rows
allow, a detour nests — its relay takes the lane just outside its
sibling's, on the same flank (a depth floor plus a side tie in the
pack/balance passes), so the sibling visibly skips it and the detour
reads as the wider arc it is. Only a viewport too short for the extra
lane folds detours onto the sibling's lane, weakest first, re-measuring
after each — the last resort, never the default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three or more repeaters that relay each other in every order are a
strongly-connected component the left-to-right flow graph can't order. The
bidir-pair merge collapsed all of them onto one column, where their markers and
labels piled up illegibly (labels rammed into the trunk line, the ordering
lost). A two-node pair draws fine as a vertical; three-plus does not.
Contract each such knot to a single super-node — the standard SCC-condensation
move, the one honest way to seat a cycle in a DAG layout. pathgraph gains
`bidir_clusters`, which finds the 3+ mutually-linked groups; node detail
rewrites every route's draw sequence so a knot's members become one synthetic
cluster stop, drawn as a single marker labelled by count and shared type
("3 repeaters", or "n nodes" when mixed). Only the graph geometry contracts —
each route row and its trace spec keep every member named in order, so the list
under the graph still carries the exact ordering the marker folds away, and a
trace still arms on the real path. The tidy two-node pair is left exactly as it
was.
Also completes _collapse's emphasis handling: routes made identical by the
contraction now fold to one line that lights when either of its rows is
selected, keeping the strongest priority for geometry and the emphasised copy's
colour for the highlight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Selecting a different route on the Node detail Routes tab relaid the whole fan:
priority drove both the geometry (which route is the spine, who owns a shared
relay, the lane packing) and the highlight, and the caller raised the selected
route's priority — so changing the pick seized the centre lane and shuffled
every marker.
Split the two. PathLayer gains an `emphasis` field that governs only the draw
colour and z-order (the highlighted route drawn on top, winning any shared
cell); `priority` alone fixes the geometry. Node detail now pins priority to a
fixed evidence order (route 0, the best-evidence route, is always the spine)
and varies only emphasis with the selection, so the picture stays put and just
repaints — the selected route white, the rest grey, off-route labels muted.
Emphasis wins a shared edge over a higher-priority spine, so a highlighted
alternative paints its whole run rather than dropping out where it overlaps.
The Message paths caller is untouched (emphasis defaults to 0, so its colour
still falls to priority exactly as before).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Column-packing already collapsed a per-path lane down to the rows a
column actually needs, but it left each node on the *side* of the spine
its jog-order lane fell on. The jog order seats relay-sharing routes
adjacent, which piles column-sharing routes onto the same flank — so a
five-route fan through at most two nodes per column still drew five lanes
whenever one column stacked two above the spine and another stacked two
below, the band being as tall as the deeper flank plus the other.
Balance the flanks instead: 2-colour the alternatives (above/below) to
flatten the deeper flank, so a same-column pair splits across the spine
and the band shrinks to spine-plus-one-each-side. A hard ceiling keeps
balancing from ever drawing a taller band than the jog order would — a
column that genuinely stacks three nodes forces one flank two deep, and
padding the opposite flank to match it (prettier, but a row taller) is
refused. A graph the jog order already draws flat keeps its airy layout
rather than lopsiding a row shorter, since its one-sided alternative has
the same deepest flank and loses the balance tie-break.
Across the live topology this tightens 23 graphs (one by two lanes, e.g.
7bc505 5 -> 3) and leaves 78 unchanged, none taller. The best spine stays
straight where the flanks come out even and leans only gently otherwise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A lane per route stacked the band as tall as the graph had routes, even
where the routes barely overlapped: TSFCT SUTTON-680M's five observed
routes drew five lanes deep though at no column did more than two nodes
coexist. A lane is a whole-width row, but two routes need distinct rows
only in the columns where they both carry a node — endpoint to endpoint
every route already shares the origin and us.
So after _assign_lanes seats the routes (best centred, sharing routes
adjacent), _compress_lanes squeezes them: each node keeps its side of the
best spine but slides to the innermost free row in its own column. A
column with one off-spine node claims one flanking row however many routes
fan past it; only a column where routes genuinely stack pays for deeper
rows. SUTTON now draws three lanes / ten rows instead of five / sixteen.
The best path's nodes stay on the spine, so it still runs straight through
the centred endpoints; the even/odd endpoint-centring is untouched (its
single-column shapes can't compress). Adds pack/stack tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The origin and destination rode the best path's discrete lane, which for
an even lane count is the upper of the two central lanes — so they, and
the spine, sat half a lane above centre. Anchor them at the band's
geometric centre (max_lane / 2.0) instead: identical to the best lane
for odd counts (spine stays dead straight), the midpoint of the two
central lanes for even counts.
Back that with a row-quantized vertical layout so the centring is exact.
Lanes now sit on integer text rows a fixed pitch apart (_LANE_PITCH_ROWS
= 3, two blank padding rows per gap) rather than a continuous dot step;
on an even count the two central lanes are opened one extra row apart, so
the endpoints land on the exact middle row between them instead of a
fractional row that snapped off centre. The band's top is anchored to a
cell-mid row so integer lane rows land dead-on with no per-lane snap
drift.
The public lane_step (dots) parameter becomes lane_pitch (rows); the
node-detail override is dropped so that page shares the 2-row pitch,
keeping only its larger max_rows for busy nodes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A pair walked in both directions is a 2-cycle in the edge set; the
longest-path relaxation loops through it and inflates every downstream
depth toward the node-count cap, dragging other relays hard against the
ends (a one-hop relay landed at x=0.09 instead of its natural 0.33).
Rank balanced_x over the graph with each such pair merged to one
representative — the acyclic flow the picture really is; the pair, drawn
as a single vertical, rightly shares an x anyway.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A relay a balanced rank pins near a canvas edge centred its long label
off-canvas, placed nothing, and — unlike an endpoint — had no inward-slide
or side-placement fallback, so its marker went silently unlabelled. Give
every node the endpoint's inward-anchor clamp and marker_label fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every node now sits on a level platform: an edge leaves level, shifts
once, arrives level, so an off-lane relay reads as a flat-topped
trapezium instead of a pointed peak or valley. The shift itself is a
cubic-bezier S-curve with horizontal tangents at both ends, easing out
of one platform and settling into the next with no corner anywhere.
Every edge is keyed by its unordered node pair and drawn exactly once,
killing the doubled/offset lines that came from a pair walked in both
directions. That bidirectional pair is the sole edge drawn as a bare
straight segment (near-vertical when the layout stacks the markers) —
the one place an up-and-down line is the honest picture.
A direction arrow embeds in the trunk just before us, so the whole
graph reads as a directed run node → us.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The contact page's route graph fanned every path as a free lane from the two
endpoints and averaged shared relays together, so a well-connected node drew as
an acute tangle of near-overlapping lines. Worse, the same physical node could
appear twice: a 1-byte trace hop (27) standing beside its own wide contact id
(27d4396a2967) whenever the short hash was too ambiguous to widen — YUL-Poly
drew YUL-UpperSalaberry as two separate markers.
Redraw the route graph as a layered left-to-right graph (pathgraph.py):
* longest-path ranks put every node in the column of its hop distance, so each
edge steps strictly forward and a relay shared by several routes is drawn once;
* an edge that skips a column routes through invisible waypoints, bending around
the nodes between its ends instead of slicing through them;
* nodes are ordered to cut crossings (barycentre sweeps) then spread down the
full height with their ys pulled toward their neighbours' (PAVA packing), which
straightens each route into a lane and opens the branch angles up;
* first-appearance ordering keeps the layout identical on every repaint.
Kill the double-drawn node at two levels. build_topology now coalesces an
under-specified id into the one longer node it can only be (27 -> 27d4396a2967),
folding their links — fixing the atlas and the scenario ranker too, not just this
graph — while leaving a genuinely ambiguous stub (c5 -> two nodes) alone. The
widget then closes the same gap locally across its own layers, catching a hash
that is ambiguous against the whole contact list but unmistakable among these
paths (be -> be1d1c1dbc4b).
On the node detail page specifically: name every node, not just the two ends
(a relay reads by its contact name, unknown ones keep the honest hash byte), and
draw the target with its own map glyph (repeater, room, sensor) to match the
header. us stays on the left, the target on the right.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fan-lane route graph clipped every label to a fixed 12-cell budget, so
node names past twelve characters ellipsized even with room to spare. Cap the
label at the physical canvas width instead: a name is kept whole unless it is
wider than the whole canvas (nowhere for the cells to go), and the placement
pass — which already slides an endpoint's label inward or drops a crowded
relay's — decides where it lands. Message-paths endpoints now read in full.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both trace tools now feed the boards: on a successful trace the session
derives the walk's spec, route, and stats from the reply and offers it to
every discipline, and a walk that sets a record surfaces a ★ note under the
run's aggregates (the walk still records nothing extra — it's the same one
trace per transmission). Scoring is defensive: a hiccup never breaks a
trace.
Trace path can open armed on a given spec, so the trophy case's Trace this
path reopens a record's exact route ready to walk again. The shared route
graph is unchanged; its docstring just drops the pathfinder example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Message paths dialog's braille route graph becomes ui/pathgraph.py —
layered paths with per-layer colour and draw priority, caller-supplied
glyph/label callbacks — so the Pathfinder can draw best-ever/session/attempt
routes through the same widget. Endpoint labels lose their beside-the-marker
restriction: they place above/below like relay labels (with an edge-clamped
anchor so a name at the canvas margin still lands), falling back beside only
as a last resort.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>