Path graph: centre the endpoints on a row-quantized lane layout
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>
This commit is contained in:
1 parent
25922efb0b
commit
8f6fa6b6ae
3 files changed
+239
-35
No files matched your search
@@ -74,12 +74,12 @@ _MAP_ROWS = 7
|
||||
#: Route-graph tuning for this page. It draws the contact on the left and us on the right
|
||||
#: (node → us, so the graph reads left to right as the inbound direction its packets
|
||||
#: travelled to reach us). A well-connected node offers several candidate paths at once, and
|
||||
#: cramming them into a short box compresses the lanes together — so this page gives each lane
|
||||
#: a generous step and row budget, letting each route spread into its own clearly separated
|
||||
#: track. The height is adaptive: it grows only with the number of distinct
|
||||
#: lanes, so a node with one or two routes still draws compact while a busy one earns the room
|
||||
#: it needs. The page scrolls (PgUp/PgDn), so a tall graph never crowds the action rows off.
|
||||
_PATH_LANE_STEP = 15
|
||||
#: cramming them into a short box compresses the lanes together — so this page keeps the shared
|
||||
#: lane pitch but grants a generous row budget, letting a busy node spread each route into its
|
||||
#: own clearly separated track instead of scaling the pitch down. The height is adaptive: it
|
||||
#: grows only with the number of distinct lanes, so a node with one or two routes still draws
|
||||
#: compact while a busy one earns the room it needs. The page scrolls (PgUp/PgDn), so a tall
|
||||
#: graph never crowds the action rows off.
|
||||
_PATH_MAX_ROWS = 22
|
||||
|
||||
#: A drawn route is dropped as *stale* when its freshest-limiting link — the stalest hop it
|
||||
@@ -287,7 +287,6 @@ class NodeDetailScreen(Screen):
|
||||
label_of=path.label_of, # type: ignore[arg-type]
|
||||
label_rgb_of=path.label_rgb_of, # type: ignore[arg-type]
|
||||
max_rows=_PATH_MAX_ROWS,
|
||||
lane_step=_PATH_LANE_STEP,
|
||||
)
|
||||
caption = Text("node → you, as heard · white = best route", style="faint")
|
||||
lines.extend(render_lines(caption, width, no_wrap=True))
|
||||
|
||||
+117
-28
@@ -15,11 +15,14 @@ now draws the fan as a **multilane highway**: a node always sits on a level plat
|
||||
lane, and a route changes lane only *between* nodes, easing across on a single gentle shift
|
||||
the way a car drifts one lane over and then stays there. Concretely:
|
||||
|
||||
* **lanes** — each distinct path owns one horizontal lane; the highest-priority (drawn last,
|
||||
e.g. the selected or best-evidence) path takes the centre lane, running dead straight
|
||||
through the two endpoints as the flow's spine, and the alternatives lie above and below it.
|
||||
The lanes are ordered to seat routes that share relays near each other, so a shared hop
|
||||
costs the shortest possible detour;
|
||||
* **lanes** — each distinct path owns one horizontal lane, the lanes a fixed pitch of text rows
|
||||
apart; the highest-priority (drawn last, e.g. the selected or best-evidence) path takes the
|
||||
centre lane and the alternatives lie above and below it. The two endpoints sit at the vertical
|
||||
centre of the lane band, so the strongest route runs through them as the flow's spine — dead
|
||||
straight when the lanes are odd (its own lane is the centre), easing gently when they are even,
|
||||
where one extra padding row is opened between the two central lanes so the endpoints land on an
|
||||
exact centred row between them. The lanes are ordered to seat routes that share relays near
|
||||
each other, so a shared hop costs the shortest possible detour;
|
||||
* **columns** (x) place each node by *balanced* rank — its distance from the origin over its
|
||||
distance-plus-remaining-distance to us — so a path's relays spread evenly between the two
|
||||
ends however long the other paths are, and a shared relay lands in one place;
|
||||
@@ -62,12 +65,14 @@ from .mapcanvas import RGB, MapCanvas, parse_hex
|
||||
SRC_NODE = "\x00src"
|
||||
DST_NODE = "\x00dst"
|
||||
|
||||
#: Vertical dot separation aimed for between adjacent lanes — the base height one lane maps
|
||||
#: to. Wide enough to seat a marker and its label row; a graph with many lanes compresses it
|
||||
#: to fit the row budget, and a graph with few lanes never stretches *past* it (spreading the
|
||||
#: lanes further apart would only reintroduce the empty space the layout exists to avoid), so
|
||||
#: a sparse graph draws compact rather than splayed.
|
||||
_LANE_STEP_DOTS = 14
|
||||
#: Text rows between adjacent lane markers — the pitch one lane maps to. At the default there
|
||||
#: are two blank rows padding each gap (room for a lane's label and its neighbour's), and an
|
||||
#: *even* lane count opens one extra row between the two central lanes so the endpoints land on
|
||||
#: an exact centred row (see the vertical-sizing block). A graph with more lanes than fit the
|
||||
#: row budget scales the pitch down to fit, and a graph with few lanes never stretches *past* it
|
||||
#: (spreading the lanes apart would only reintroduce the empty space the layout exists to
|
||||
#: avoid), so a sparse graph draws compact rather than splayed.
|
||||
_LANE_PITCH_ROWS = 3
|
||||
|
||||
#: Dots reserved beyond the outermost lane at each end of the graph — a label row for that
|
||||
#: lane's markers, plus a little air.
|
||||
@@ -89,7 +94,7 @@ _GRAPH_PAD_DOTS = 6
|
||||
#: the curve as a fraction of the gap — rather than by a fixed slope keyed off the vertical
|
||||
#: offset — keeps the platform-to-curve proportion constant however wide or narrow the columns
|
||||
#: fall.
|
||||
_CURVE_SPAN = 0.75
|
||||
_CURVE_SPAN = 0.8
|
||||
|
||||
#: The shortest horizontal run a lane change is given even for a one-lane hop, so a tight
|
||||
#: column gap still bends across a few dots rather than snapping over in one abrupt step.
|
||||
@@ -259,7 +264,7 @@ def render_path_graph(
|
||||
label_rgb_of: LabelRgbOf,
|
||||
min_rows: int = 5,
|
||||
max_rows: int = 15,
|
||||
lane_step: int = _LANE_STEP_DOTS,
|
||||
lane_pitch: int = _LANE_PITCH_ROWS,
|
||||
) -> list[str]:
|
||||
"""Draw the diverge/converge route-flow graph and return its ANSI lines.
|
||||
|
||||
@@ -277,9 +282,11 @@ def render_path_graph(
|
||||
min_rows: The fewest canvas rows to draw, however few the lanes.
|
||||
max_rows: The most canvas rows to spend; a graph with more lanes than fit
|
||||
compresses its lane spacing rather than growing past this.
|
||||
lane_step: Vertical dot separation aimed for between lanes. A larger step opens the
|
||||
lanes apart; the row budget compresses it when there are many lanes, and never
|
||||
stretches past it when there are few. Defaults to :data:`_LANE_STEP_DOTS`.
|
||||
lane_pitch: Text rows between adjacent lane markers (so ``lane_pitch - 1`` blank rows
|
||||
pad each gap). An even lane count opens one extra row between the two central lanes
|
||||
so the endpoints land on an exact centred row. The row budget compresses the pitch
|
||||
when there are many lanes, and never stretches it when there are few. Defaults to
|
||||
:data:`_LANE_PITCH_ROWS`.
|
||||
|
||||
Returns:
|
||||
One ANSI string per canvas row (empty when there are no layers to draw).
|
||||
@@ -315,39 +322,68 @@ def render_path_graph(
|
||||
for i in sorted(range(len(drawn)), key=lambda j: -drawn[j].priority):
|
||||
for node in seqs[i]:
|
||||
owner.setdefault(node, i)
|
||||
# A path that is only a detour off a sibling (that sibling's route with an extra relay
|
||||
# inserted) is subsumed too: its relay is re-owned onto the sibling's lane so it costs no
|
||||
# band and needs no crossing to rejoin the shared node. See :func:`_fold_detours`.
|
||||
_fold_detours(drawn, seqs, owner, xfrac, width)
|
||||
lane_of_path = _assign_lanes(drawn, seqs, owner, best)
|
||||
|
||||
# The endpoints sit on the best path's lane, where the strongest route runs dead straight
|
||||
# across as the graph's spine; the alternatives fan above and below it.
|
||||
# The endpoints sit at the vertical centre of the lane band, where the strongest route runs
|
||||
# through them as the graph's spine; the alternatives fan above and below. For an odd lane
|
||||
# count that centre *is* the best path's own lane, so the spine runs dead straight; for an
|
||||
# even count it is the midpoint between the two central lanes (the best path takes the upper
|
||||
# of the two), so the endpoints stay centred and a best path carrying relays of its own eases
|
||||
# half a lane to reach them rather than seating the whole graph off-centre.
|
||||
max_lane = max(lane_of_path.values())
|
||||
centre_lane = float(lane_of_path[best])
|
||||
centre_lane = max_lane / 2.0
|
||||
node_lane: dict[str, float] = {
|
||||
node: (centre_lane if node in (SRC_NODE, DST_NODE) else float(lane_of_path[owner[node]]))
|
||||
for node in ordered_nodes
|
||||
}
|
||||
|
||||
# -- Vertical sizing: size the rows to the lanes, compressing the spacing (never
|
||||
# stretching it past ``lane_step``) so a busy graph fits and a sparse one stays compact.
|
||||
# -- Vertical sizing. Each lane sits on its own text row, ``lane_pitch`` rows apart (so
|
||||
# ``lane_pitch - 1`` blank rows pad each gap). On an *even* lane count the two central lanes
|
||||
# are opened one extra row apart, so the endpoints — pinned to the band's centre — land on
|
||||
# the exact middle row between them rather than on a fractional row that would snap off it;
|
||||
# an *odd* count already seats a central lane there for them to ride. A graph too tall for
|
||||
# ``max_rows`` scales every row down proportionally (never up), so it stays compact.
|
||||
even_lanes = max_lane % 2 == 1 # N = max_lane + 1 lanes; even ⟺ max_lane odd
|
||||
lower_centre = max_lane // 2 + 1 # first lane below the centre (only meaningful when even)
|
||||
|
||||
def slot(lane: float) -> float:
|
||||
"""The text row (pre-scaling) a lane index maps to, with the even-count centre gap."""
|
||||
rows_out = lane * lane_pitch
|
||||
if even_lanes and lane > lower_centre - 1:
|
||||
# the lower-central lane and everything below it are pushed one row down; the
|
||||
# endpoints' half-lane takes half of it, landing them on the widened gap's middle.
|
||||
rows_out += min(1.0, lane - (lower_centre - 1))
|
||||
return rows_out
|
||||
|
||||
if max_lane <= 0:
|
||||
step = 0.0
|
||||
rows = min_rows
|
||||
lane_rows = 0.0
|
||||
scale = 0.0
|
||||
else:
|
||||
ideal = max_lane * lane_step + 2 * _GRAPH_END_DOTS
|
||||
lane_rows = slot(float(max_lane)) # total lane span, in rows
|
||||
ideal = lane_rows * 4 + 2 * _GRAPH_END_DOTS
|
||||
rows = max(min_rows, min(max_rows, ceil(ideal / 4)))
|
||||
avail = rows * 4 - 2 * _GRAPH_END_DOTS
|
||||
step = min(float(lane_step), avail / max_lane)
|
||||
scale = min(1.0, avail / (lane_rows * 4)) if lane_rows else 0.0
|
||||
|
||||
canvas = MapCanvas(width, rows)
|
||||
dot_w = width * 2
|
||||
span = dot_w - 2 * _GRAPH_PAD_DOTS
|
||||
band = max_lane * step
|
||||
top = (rows * 4 - band) / 2 # centre the lane band in the chosen rows
|
||||
band = lane_rows * 4 * scale
|
||||
# Centre the band, anchored on a cell-mid row so the (unscaled) integer lane rows land dead
|
||||
# on their cells — no per-lane snap drift that would nudge a centred endpoint off its middle.
|
||||
top = float(_mid_row((rows * 4 - band) / 2))
|
||||
|
||||
def x_of(node: str) -> int:
|
||||
return _GRAPH_PAD_DOTS + round(xfrac[node] * span)
|
||||
|
||||
pos: dict[str, tuple[int, int]] = {
|
||||
node: (x_of(node), _mid_row(top + node_lane[node] * step)) for node in ordered_nodes
|
||||
node: (x_of(node), _mid_row(top + slot(node_lane[node]) * 4 * scale))
|
||||
for node in ordered_nodes
|
||||
}
|
||||
# -- Edges. Collect every edge once, keyed by its unordered node pair: an edge two routes
|
||||
# share — or a pair walked in *both* directions — must draw a single time, else it silts up
|
||||
@@ -398,7 +434,7 @@ def _route(
|
||||
:data:`_CURVE_SPAN` of the column gap (a gentle drift, not a slope keyed off the drop) and
|
||||
sits centred in it, so the platforms flank it evenly; when the gap is too tight to hold a
|
||||
curve of even :data:`_MIN_SHIFT_DOTS`, the bend simply spans the whole gap. The endpoints,
|
||||
sitting on the centre lane,
|
||||
sitting at the centre of the lane band,
|
||||
make the origin's diverging peels and us's converging merges fall out of this one rule —
|
||||
no endpoint special case. The lone exception is ``bidir``: a pair walked both ways draws as
|
||||
a single straight segment between the markers (a near-vertical when the layout stacks
|
||||
@@ -524,6 +560,59 @@ def _longest_paths(ordered_nodes: list[str], edges: set[tuple[str, str]]) -> dic
|
||||
return depth
|
||||
|
||||
|
||||
def _fold_detours(
|
||||
drawn: Sequence[PathLayer],
|
||||
seqs: Sequence[tuple[str, ...]],
|
||||
owner: dict[str, int],
|
||||
xfrac: dict[str, float],
|
||||
width: int,
|
||||
) -> None:
|
||||
"""Re-own a *detour* path's extra relays onto the sibling lane it converges into.
|
||||
|
||||
A route heard as another route *plus* an inserted relay or two — same convergence into us,
|
||||
one extra hop on the way — shouldn't cost a whole extra lane. Given its own lane it would
|
||||
sit on the far side of the best-centred spine from the sibling it rejoins, dragging its
|
||||
inserted relay clear across the graph to meet the shared node (an avoidable crossing).
|
||||
|
||||
So when a bearing path's relays, minus the ones it alone owns, exactly match a
|
||||
higher-or-equal-priority sibling's relays — and that sibling truly owns them (is their
|
||||
lane) — the path is that sibling with a detour. Its owned relays are re-owned to the
|
||||
sibling, riding its lane as waypoints the branch dips through, provided none shares a cell
|
||||
column with a node *already on that lane* — including a detour folded there earlier, so two
|
||||
siblings that insert a relay at the same column don't overprint (the second keeps its own
|
||||
lane). The detour path then owns nothing, earns no lane, and reads as a branch *off* its
|
||||
sibling instead of a track that crosses the graph to reach it. Mutates ``owner`` in place.
|
||||
"""
|
||||
span = width * 2 - 2 * _GRAPH_PAD_DOTS
|
||||
|
||||
def col(node: str) -> int:
|
||||
return (_GRAPH_PAD_DOTS + round(xfrac[node] * span)) >> 1
|
||||
|
||||
relays = [
|
||||
frozenset(n for n in seq if n not in (SRC_NODE, DST_NODE)) for seq in seqs
|
||||
]
|
||||
# Weakest first, so a marginal detour folds onto its stronger sibling, never the reverse.
|
||||
for i in sorted(range(len(drawn)), key=lambda j: drawn[j].priority):
|
||||
own_i = {n for n in seqs[i] if owner[n] == i}
|
||||
residual = relays[i] - own_i
|
||||
if not own_i or not residual:
|
||||
continue
|
||||
for q in range(len(drawn)):
|
||||
if q == i or drawn[q].priority < drawn[i].priority or relays[q] != residual:
|
||||
continue
|
||||
if not all(owner[n] == q for n in relays[q]): # q must own (be the lane of) them
|
||||
continue
|
||||
# Columns already committed on q's lane (its own relays plus any earlier fold).
|
||||
q_cols = {
|
||||
col(n) for n, o in owner.items() if o == q and n not in (SRC_NODE, DST_NODE)
|
||||
}
|
||||
new_cols = {col(n) for n in own_i}
|
||||
if len(new_cols) == len(own_i) and q_cols.isdisjoint(new_cols):
|
||||
for n in own_i:
|
||||
owner[n] = q
|
||||
break
|
||||
|
||||
|
||||
def _assign_lanes(
|
||||
drawn: Sequence[PathLayer],
|
||||
seqs: Sequence[tuple[str, ...]],
|
||||
|
||||
@@ -12,6 +12,8 @@ from meshterm.ui.pathgraph import (
|
||||
PathLayer,
|
||||
_balanced_x,
|
||||
_coalesce_prefixes,
|
||||
_collapse,
|
||||
_fold_detours,
|
||||
_route,
|
||||
render_path_graph,
|
||||
)
|
||||
@@ -133,6 +135,49 @@ def test_a_shared_relay_draws_a_single_marker() -> None:
|
||||
assert plain.count("aa") == 1 and plain.count("cc") == 1
|
||||
|
||||
|
||||
def _marker_rows(lines, glyph): # noqa: ANN001, ANN202
|
||||
"""The distinct canvas row indices a marker glyph is drawn on (endpoints ``★``, relays ``●``)."""
|
||||
return sorted({i for i, ln in enumerate(_ANSI.sub("", "\n".join(lines)).splitlines()) if glyph in ln})
|
||||
|
||||
|
||||
def test_even_lane_count_centres_the_endpoints() -> None:
|
||||
"""With an even number of lanes the origin and destination sit exactly midway between them.
|
||||
|
||||
Two disjoint one-relay routes make two lanes — an upper and a lower. The endpoints ride
|
||||
neither: an extra padding row is opened between the two central lanes so they land on the
|
||||
exact centred row, rather than pinned to the upper lane (where the best path's discrete lane
|
||||
would put it) or snapped a row off centre.
|
||||
"""
|
||||
layers = [
|
||||
PathLayer(("aa",), WHITE, 4), # best -> upper lane
|
||||
PathLayer(("bb",), GREY, 2), # -> lower lane
|
||||
]
|
||||
lines = _render(layers, label_of=lambda _n: None) # markers only — no label rows to muddy
|
||||
(star,) = _marker_rows(lines, "★") # both endpoints share the one centre row
|
||||
top, bottom = _marker_rows(lines, "●") # the two relays sit on their own lanes
|
||||
assert top < star < bottom # centred between the lanes, not pinned to either
|
||||
assert star - top == bottom - star # exact integer centring, thanks to the extra pad row
|
||||
|
||||
|
||||
def test_odd_lane_count_seats_endpoints_on_the_straight_middle_lane() -> None:
|
||||
"""An odd lane count gives the endpoints a real middle lane to ride — spine dead straight.
|
||||
|
||||
Three disjoint one-relay routes make three evenly spaced lanes; the best path's relay owns
|
||||
the centre lane. The origin and destination share that centre row (no bend, no extra pad
|
||||
row), sitting midway between the two outer lanes.
|
||||
"""
|
||||
layers = [
|
||||
PathLayer(("mm",), WHITE, 4), # best -> centre lane
|
||||
PathLayer(("aa",), GREY, 2), # -> a flanking lane
|
||||
PathLayer(("bb",), GREY, 1), # -> the other flanking lane
|
||||
]
|
||||
lines = _render(layers, label_of=lambda _n: None)
|
||||
(star,) = _marker_rows(lines, "★")
|
||||
top, mid, bottom = _marker_rows(lines, "●") # three relays, one per lane
|
||||
assert star == mid # endpoints ride the centre lane — same row as the best relay
|
||||
assert mid - top == bottom - mid # lanes evenly spaced: no extra gap on an odd count
|
||||
|
||||
|
||||
def test_every_label_shows_on_a_busy_graph() -> None:
|
||||
"""Four distinct routes lay out as separated lanes — every relay label lands, none dropped."""
|
||||
names = {
|
||||
@@ -150,6 +195,77 @@ def test_every_label_shows_on_a_busy_graph() -> None:
|
||||
assert name in plain, f"{name} was dropped from the graph"
|
||||
|
||||
|
||||
def _owner_after_fold(layers, width=72): # noqa: ANN001
|
||||
"""Build the per-node owner map and run the detour fold, as ``render_path_graph`` does."""
|
||||
drawn = _collapse(_coalesce_prefixes(layers))
|
||||
seqs = [(SRC_NODE, *layer.hops, DST_NODE) for layer in drawn]
|
||||
ordered: list[str] = []
|
||||
seen: set[str] = set()
|
||||
edges: set[tuple[str, str]] = set()
|
||||
for seq in seqs:
|
||||
for node in seq:
|
||||
if node not in seen:
|
||||
seen.add(node)
|
||||
ordered.append(node)
|
||||
edges.update(zip(seq, seq[1:]))
|
||||
xfrac = _balanced_x(ordered, edges)
|
||||
owner: dict[str, int] = {}
|
||||
for i in sorted(range(len(drawn)), key=lambda j: -drawn[j].priority):
|
||||
for node in seqs[i]:
|
||||
owner.setdefault(node, i)
|
||||
_fold_detours(drawn, seqs, owner, xfrac, width)
|
||||
lanes = sum(1 for i in range(len(drawn)) if any(owner[n] == i for n in seqs[i]))
|
||||
return owner, lanes
|
||||
|
||||
|
||||
def test_detour_path_rides_its_siblings_lane_not_a_new_one() -> None:
|
||||
"""A route that is a sibling *plus* one inserted relay folds onto that sibling's lane.
|
||||
|
||||
``cc`` inserts a relay before the ``bb`` its sibling also converges through. Given its own
|
||||
lane on the far side of the best spine, it would drag that relay clear across the graph to
|
||||
rejoin ``bb`` (the crossing). Folded, ``cc`` is re-owned to ``bb``'s lane — two lanes, no
|
||||
crossing. This is the real YUL-Poly shape: CDN-FENDALL1 → UpperSalaberry.
|
||||
"""
|
||||
layers = [
|
||||
PathLayer(("xx",), WHITE, 3), # best spine: SRC -> xx -> DST
|
||||
PathLayer(("bb",), GREY, 2), # sibling: SRC -> bb -> DST
|
||||
PathLayer(("cc", "bb"), GREY, 2), # detour: SRC -> cc -> bb -> DST
|
||||
]
|
||||
owner, lanes = _owner_after_fold(layers)
|
||||
assert owner["cc"] == owner["bb"] # cc rides bb's lane rather than earning its own
|
||||
assert lanes == 2
|
||||
|
||||
|
||||
def test_disjoint_routes_are_never_folded() -> None:
|
||||
"""Routes that share no convergence relay each keep their own lane — folding stays targeted."""
|
||||
layers = [
|
||||
PathLayer(("xx",), WHITE, 3),
|
||||
PathLayer(("aa",), GREY, 2),
|
||||
PathLayer(("bb",), GREY, 2),
|
||||
]
|
||||
_owner, lanes = _owner_after_fold(layers)
|
||||
assert lanes == 3
|
||||
|
||||
|
||||
def test_two_detours_sharing_a_column_do_not_overprint() -> None:
|
||||
"""Two siblings inserting a relay at the same column can't both fold onto the shared lane.
|
||||
|
||||
``cc`` and ``dd`` each sit one hop off the origin feeding the same ``bb``, so they share a
|
||||
column; folding both onto ``bb``'s lane would stack two markers in one cell. Exactly one
|
||||
folds; the other keeps a lane of its own.
|
||||
"""
|
||||
layers = [
|
||||
PathLayer(("xx",), WHITE, 3),
|
||||
PathLayer(("bb",), GREY, 2),
|
||||
PathLayer(("cc", "bb"), GREY, 2),
|
||||
PathLayer(("dd", "bb"), GREY, 1),
|
||||
]
|
||||
owner, lanes = _owner_after_fold(layers)
|
||||
folded = [n for n in ("cc", "dd") if owner[n] == owner["bb"]]
|
||||
assert len(folded) == 1 # only one rides bb's lane; the other stays separate
|
||||
assert lanes == 3
|
||||
|
||||
|
||||
def test_edge_pinned_relay_labels_are_not_dropped() -> None:
|
||||
"""A relay a balanced rank pins near a canvas edge still shows its whole name.
|
||||
|
||||
|
||||
Reference in new issue
Block a user