termrender 4.6.1__tar.gz → 4.6.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {termrender-4.6.1 → termrender-4.6.2}/CHANGELOG.md +22 -0
- {termrender-4.6.1 → termrender-4.6.2}/PKG-INFO +1 -1
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow.py +19 -1
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow_layout.py +120 -54
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_flow.py +87 -2
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_flow_corpus.py +12 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_flow_layout.py +108 -18
- {termrender-4.6.1 → termrender-4.6.2}/.github/workflows/publish.yml +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/.gitignore +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/CLAUDE.md +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/LICENSE +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/README.md +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/design.json +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/pyproject.toml +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/requirements.json +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/scripts/mermaid_flow_parity.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/__init__.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/__main__.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/blocks.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/emit.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/layout.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/parser.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/py.typed +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/code.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/table.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/text.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/src/termrender/style.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/__init__.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_charts.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_cli_contract.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_column_alignment.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_diff.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_inline_badge.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_linebreak.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_flow_parser.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_flow_shapes.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_myst_gaps.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_stat.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_tasklist.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_timeline.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/tests/test_variable_colons.py +0 -0
- {termrender-4.6.1 → termrender-4.6.2}/uv.lock +0 -0
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.6.2 (2026-07-07)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **mermaid-flow**: Preserve edge labels, render self-loops, sanitize degraded echo
|
|
9
|
+
([`263100f`](https://github.com/crouton-labs/termrender/commit/263100f16e1a7f0044497f2dedd0fd10f0831626))
|
|
10
|
+
|
|
11
|
+
- Router now draws all edge polylines+arrowheads first, then all edge labels last (reserving each
|
|
12
|
+
label's cells, plus a margin), so a later edge's line can never silently erase an earlier edge's
|
|
13
|
+
label and two labels sharing a lane render with visible separation instead of concatenating. -
|
|
14
|
+
Rank-band gaps widen to fit an adjacent-rank edge's label in full (_rank_gap_overrides), fixing
|
|
15
|
+
short LR/forward-adjacent labeled edges (e.g. "hello") that were previously clipped to a couple of
|
|
16
|
+
characters. - Raw-echo degradation now strips any box-drawing/geometric glyph (\u2500-\u259f,
|
|
17
|
+
\u25a0-\u25ff) present in the malformed source itself, so a degraded echo can never be misdetected
|
|
18
|
+
as a successful render. - Hardened green-theatre tests: self-loop tests now assert real loop
|
|
19
|
+
geometry (arrowhead + glyphs past the box border) instead of "non-empty output"; the
|
|
20
|
+
edge-visibility test now inspects the guaranteed-clear inter-box gap instead of accepting
|
|
21
|
+
box-border glyphs; cycle/labeled-back-edge/direction/never-raises tests gained genuine
|
|
22
|
+
topology/degradation assertions in place of crash-only checks. - Updated the two corpus goldens
|
|
23
|
+
(pipe-label, inline-label) whose rendered height changed under the new label-aware gap widening.
|
|
24
|
+
|
|
25
|
+
|
|
4
26
|
## v4.6.1 (2026-07-07)
|
|
5
27
|
|
|
6
28
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.6.
|
|
3
|
+
Version: 4.6.2
|
|
4
4
|
Summary: Rich terminal rendering of directive-flavored markdown
|
|
5
5
|
Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
|
|
6
6
|
Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
|
|
@@ -54,11 +54,21 @@ Known degradations (by design, not bugs)
|
|
|
54
54
|
|
|
55
55
|
from __future__ import annotations
|
|
56
56
|
|
|
57
|
+
import re
|
|
58
|
+
|
|
57
59
|
from termrender.renderers.mermaid_flow_layout import layout_flowgraph
|
|
58
60
|
from termrender.renderers.mermaid_flow_parser import FlowchartError, parse
|
|
59
61
|
|
|
60
62
|
__all__ = ["render_flowchart"]
|
|
61
63
|
|
|
64
|
+
# Same ranges the downstream attach viewer uses to detect render *success*
|
|
65
|
+
# (see the module docstring's "Degradation contract"). The raw-echo path
|
|
66
|
+
# must never contain one of these, even when the malformed/degenerate
|
|
67
|
+
# source itself happens to contain a literal box-drawing or geometric
|
|
68
|
+
# glyph (e.g. hand-typed "\u250c" in otherwise-non-flowchart text) — otherwise a
|
|
69
|
+
# failed render could be misdetected as a successful one.
|
|
70
|
+
_GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
|
|
71
|
+
|
|
62
72
|
|
|
63
73
|
def render_flowchart(source: str, width: int) -> list[str]:
|
|
64
74
|
"""Render a mermaid ``graph``/``flowchart`` source to unicode lines.
|
|
@@ -113,4 +123,12 @@ def render_flowchart(source: str, width: int) -> list[str]:
|
|
|
113
123
|
|
|
114
124
|
|
|
115
125
|
def _raw_echo(source: str) -> list[str]:
|
|
116
|
-
|
|
126
|
+
"""Plain echo of ``source``'s lines, with any box-drawing/geometric
|
|
127
|
+
glyph (the same ``\\u2500-\\u259f``/``\\u25a0-\\u25ff`` ranges the
|
|
128
|
+
downstream viewer checks for) replaced by ``?`` — the degradation
|
|
129
|
+
contract requires NO such glyph survive, even one present verbatim in
|
|
130
|
+
malformed/degenerate source, not just glyphs this renderer would have
|
|
131
|
+
drawn itself."""
|
|
132
|
+
return [
|
|
133
|
+
_GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()
|
|
134
|
+
]
|
|
@@ -721,6 +721,38 @@ def _box_dims(label: str, shape: NodeShape = NodeShape.RECT) -> tuple[int, int]:
|
|
|
721
721
|
_GAP_X = 3 # minimum border-to-border gap between boxes in one rank/band
|
|
722
722
|
_ROW_GAP = 2 # minimum gap between rank bands
|
|
723
723
|
_COMPONENT_GUTTER = 4 # gap between independently-laid-out components
|
|
724
|
+
_LABEL_GAP_PAD = 1 # cells of breathing room either side of a label's own text
|
|
725
|
+
# when it forces a rank-band gap wider than _ROW_GAP.
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _rank_gap_overrides(
|
|
729
|
+
edges: list[FlowEdge], rank_of: dict[str, int]
|
|
730
|
+
) -> dict[int, int]:
|
|
731
|
+
"""Minimum inter-rank-band gap keyed by the *lower* rank of each
|
|
732
|
+
adjacent-rank transition, widened past ``_ROW_GAP`` wherever a labeled
|
|
733
|
+
edge directly connects that transition's two ranks. A forward edge
|
|
734
|
+
between adjacent ranks routes as a single straight run (or a Z path
|
|
735
|
+
that still crosses the same inter-rank band) whose only clear space is
|
|
736
|
+
this gap — at the base ``_ROW_GAP`` a label wider than a couple of
|
|
737
|
+
cells has nowhere to go but onto the boxes it connects, which is
|
|
738
|
+
exactly the short LR/adjacent-rank clipped-label bug this closes.
|
|
739
|
+
Non-adjacent-rank edges (back-edges, multi-rank spans) don't constrain
|
|
740
|
+
the gap here — they route through their own side lane, not this band.
|
|
741
|
+
"""
|
|
742
|
+
overrides: dict[int, int] = {}
|
|
743
|
+
for e in edges:
|
|
744
|
+
if e.src == e.dst or not e.label:
|
|
745
|
+
continue
|
|
746
|
+
r_src = rank_of.get(e.src)
|
|
747
|
+
r_dst = rank_of.get(e.dst)
|
|
748
|
+
if r_src is None or r_dst is None:
|
|
749
|
+
continue
|
|
750
|
+
lo_r, hi_r = (r_src, r_dst) if r_src <= r_dst else (r_dst, r_src)
|
|
751
|
+
if hi_r - lo_r != 1:
|
|
752
|
+
continue
|
|
753
|
+
needed = visual_len(e.label) + 2 * _LABEL_GAP_PAD
|
|
754
|
+
overrides[lo_r] = max(overrides.get(lo_r, _ROW_GAP), needed)
|
|
755
|
+
return overrides
|
|
724
756
|
|
|
725
757
|
|
|
726
758
|
def _native_extents(
|
|
@@ -780,12 +812,15 @@ def _place_nodes(
|
|
|
780
812
|
for v in comp_vertices:
|
|
781
813
|
ranks[sug.grx[v].rank].append(v)
|
|
782
814
|
|
|
815
|
+
rank_of = {v.data: sug.grx[v].rank for v in comp_vertices}
|
|
816
|
+
gap_overrides = _rank_gap_overrides(edges, rank_of)
|
|
817
|
+
|
|
783
818
|
band_top_for_rank: dict[int, int] = {}
|
|
784
819
|
cursor = 0
|
|
785
820
|
for r in sorted(ranks):
|
|
786
821
|
band_top_for_rank[r] = cursor
|
|
787
822
|
max_h = max(native[v.data][1] for v in ranks[r])
|
|
788
|
-
cursor += max_h + _ROW_GAP
|
|
823
|
+
cursor += max_h + gap_overrides.get(r, _ROW_GAP)
|
|
789
824
|
|
|
790
825
|
min_x = min(v.view.xy[0] for v in comp_vertices)
|
|
791
826
|
provisional = {v: round(v.view.xy[0] - min_x) for v in comp_vertices}
|
|
@@ -1214,6 +1249,17 @@ def _label_positions(length: int, center: int, lo: int, hi: int) -> list[int]:
|
|
|
1214
1249
|
return order
|
|
1215
1250
|
|
|
1216
1251
|
|
|
1252
|
+
def _reserve_label_margin(canvas: Canvas, x: int, y: int) -> None:
|
|
1253
|
+
"""Mark one cell just past a label's edge as reserved, without
|
|
1254
|
+
altering whatever character (if any) already occupies it — a one-cell
|
|
1255
|
+
buffer so a second label placed on an adjacent/overlapping segment
|
|
1256
|
+
(e.g. two back-edge labels sharing a row) lands with visible separation
|
|
1257
|
+
instead of butting directly up against the first label's text."""
|
|
1258
|
+
if x < 0 or y < 0:
|
|
1259
|
+
return
|
|
1260
|
+
canvas.set_char(x, y, canvas.get_char(x, y), reserve=True)
|
|
1261
|
+
|
|
1262
|
+
|
|
1217
1263
|
def _draw_label_on_segment(
|
|
1218
1264
|
canvas: Canvas, a: tuple[int, int], b: tuple[int, int], label: str
|
|
1219
1265
|
) -> None:
|
|
@@ -1226,7 +1272,12 @@ def _draw_label_on_segment(
|
|
|
1226
1272
|
self-loop label placement. Shifts along the search axis to the nearest
|
|
1227
1273
|
span/row fully clear of a reserved (box) cell; if none exists, writes
|
|
1228
1274
|
at the ideal placement anyway (overflow tolerated) but skips individual
|
|
1229
|
-
reserved cells — a label never corrupts a box.
|
|
1275
|
+
reserved cells — a label never corrupts a box. Every written label cell
|
|
1276
|
+
is itself marked reserved (see :func:`layout_flowgraph`'s two-pass draw
|
|
1277
|
+
order): labels draw only after every edge's line+arrowheads are already
|
|
1278
|
+
on the canvas, so nothing draws over a label afterwards, and a *later*
|
|
1279
|
+
label's own clear-run search treats an earlier label's cells the same
|
|
1280
|
+
as a box's — skip, don't overwrite.
|
|
1230
1281
|
"""
|
|
1231
1282
|
length = visual_len(label)
|
|
1232
1283
|
if length <= 0:
|
|
@@ -1246,12 +1297,14 @@ def _draw_label_on_segment(
|
|
|
1246
1297
|
run = cells(start)
|
|
1247
1298
|
if not any(canvas.is_reserved(x, y) for x, y in run):
|
|
1248
1299
|
for (px, py), ch in zip(run, label):
|
|
1249
|
-
canvas.
|
|
1300
|
+
canvas.set_char(px, py, ch, reserve=True)
|
|
1301
|
+
_reserve_label_margin(canvas, start - 1, row)
|
|
1302
|
+
_reserve_label_margin(canvas, start + length, row)
|
|
1250
1303
|
return
|
|
1251
1304
|
start = max(lo, min(center - length // 2, hi - length + 1))
|
|
1252
1305
|
for (px, py), ch in zip(cells(start), label):
|
|
1253
1306
|
if not canvas.is_reserved(px, py):
|
|
1254
|
-
canvas.
|
|
1307
|
+
canvas.set_char(px, py, ch, reserve=True)
|
|
1255
1308
|
return
|
|
1256
1309
|
|
|
1257
1310
|
# Vertical segment: the label still reads horizontally, centered on
|
|
@@ -1272,11 +1325,13 @@ def _draw_label_on_segment(
|
|
|
1272
1325
|
run = row_cells(row)
|
|
1273
1326
|
if not any(canvas.is_reserved(x, y) for x, y in run):
|
|
1274
1327
|
for (px, py), ch in zip(run, label):
|
|
1275
|
-
canvas.
|
|
1328
|
+
canvas.set_char(px, py, ch, reserve=True)
|
|
1329
|
+
_reserve_label_margin(canvas, start_x - 1, row)
|
|
1330
|
+
_reserve_label_margin(canvas, start_x + length, row)
|
|
1276
1331
|
return
|
|
1277
1332
|
for (px, py), ch in zip(row_cells(center_row), label):
|
|
1278
1333
|
if not canvas.is_reserved(px, py):
|
|
1279
|
-
canvas.
|
|
1334
|
+
canvas.set_char(px, py, ch, reserve=True)
|
|
1280
1335
|
|
|
1281
1336
|
|
|
1282
1337
|
def _draw_polyline(canvas: Canvas, points: list[tuple[int, int]], style: EdgeStyle) -> None:
|
|
@@ -1322,67 +1377,61 @@ def _self_loop_points(
|
|
|
1322
1377
|
return [(ax, lo), (far, lo), (far, hi), (ax, hi)]
|
|
1323
1378
|
|
|
1324
1379
|
|
|
1325
|
-
def
|
|
1326
|
-
canvas: Canvas,
|
|
1380
|
+
def _route_edge_path(
|
|
1327
1381
|
rects: dict[str, BoxRect],
|
|
1328
1382
|
direction: Direction,
|
|
1329
1383
|
edge: FlowEdge,
|
|
1330
1384
|
lane_counter: list[int],
|
|
1331
1385
|
self_loop_counter: dict[str, int],
|
|
1332
|
-
) -> None:
|
|
1333
|
-
"""
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1386
|
+
) -> list[tuple[int, int]] | None:
|
|
1387
|
+
"""Compute one edge's polyline points: anchors chosen by relative rank
|
|
1388
|
+
position, an L/Z/C path shape. Pure path computation only — drawing is
|
|
1389
|
+
the caller's job, split into two passes across *all* edges (see
|
|
1390
|
+
:func:`layout_flowgraph`) so that no edge's line ever overwrites
|
|
1391
|
+
another edge's already-placed label. Dangling node-id references
|
|
1392
|
+
return ``None`` (defensive no-op — the parser guarantees valid
|
|
1393
|
+
endpoints; this module never crashes on one).
|
|
1337
1394
|
"""
|
|
1338
1395
|
src_rect = rects.get(edge.src)
|
|
1339
1396
|
dst_rect = rects.get(edge.dst)
|
|
1340
1397
|
if src_rect is None or dst_rect is None:
|
|
1341
|
-
return
|
|
1398
|
+
return None
|
|
1342
1399
|
|
|
1343
1400
|
if edge.src == edge.dst:
|
|
1344
1401
|
lane = self_loop_counter.get(edge.src, 0)
|
|
1345
1402
|
self_loop_counter[edge.src] = lane + 1
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1403
|
+
return _self_loop_points(direction, src_rect, lane)
|
|
1404
|
+
|
|
1405
|
+
src_extent = _rank_extent(direction, src_rect)
|
|
1406
|
+
dst_extent = _rank_extent(direction, dst_rect)
|
|
1407
|
+
same_rank = not (dst_extent[1] < src_extent[0] or src_extent[1] < dst_extent[0])
|
|
1408
|
+
|
|
1409
|
+
if same_rank:
|
|
1410
|
+
return [_facing_anchor(src_rect, dst_rect), _facing_anchor(dst_rect, src_rect)]
|
|
1411
|
+
|
|
1412
|
+
src_center = (src_extent[0] + src_extent[1]) / 2
|
|
1413
|
+
dst_center = (dst_extent[0] + dst_extent[1]) / 2
|
|
1414
|
+
forward = (dst_center > src_center) == _forward_increasing(direction)
|
|
1415
|
+
if forward:
|
|
1416
|
+
return _z_path(
|
|
1417
|
+
direction,
|
|
1418
|
+
_forward_exit(direction, src_rect),
|
|
1419
|
+
_forward_entry(direction, dst_rect),
|
|
1420
|
+
)
|
|
1351
1421
|
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
lane = lane_counter[0]
|
|
1366
|
-
lane_counter[0] += 1
|
|
1367
|
-
lane_secondary = (
|
|
1368
|
-
_lane_secondary_base(direction, src_rect, dst_rect)
|
|
1369
|
-
+ _LANE_MARGIN
|
|
1370
|
-
+ lane * _LANE_GAP
|
|
1371
|
-
)
|
|
1372
|
-
points = _lane_path(
|
|
1373
|
-
direction,
|
|
1374
|
-
_lane_anchor(direction, src_rect),
|
|
1375
|
-
_lane_anchor(direction, dst_rect),
|
|
1376
|
-
lane_secondary,
|
|
1377
|
-
)
|
|
1378
|
-
|
|
1379
|
-
_draw_polyline(canvas, points, edge.style)
|
|
1380
|
-
_draw_arrowheads(canvas, points, edge)
|
|
1381
|
-
|
|
1382
|
-
if edge.label:
|
|
1383
|
-
seg = _longest_segment(points)
|
|
1384
|
-
if seg is not None:
|
|
1385
|
-
_draw_label_on_segment(canvas, *seg, edge.label)
|
|
1422
|
+
lane = lane_counter[0]
|
|
1423
|
+
lane_counter[0] += 1
|
|
1424
|
+
lane_secondary = (
|
|
1425
|
+
_lane_secondary_base(direction, src_rect, dst_rect)
|
|
1426
|
+
+ _LANE_MARGIN
|
|
1427
|
+
+ lane * _LANE_GAP
|
|
1428
|
+
)
|
|
1429
|
+
return _lane_path(
|
|
1430
|
+
direction,
|
|
1431
|
+
_lane_anchor(direction, src_rect),
|
|
1432
|
+
_lane_anchor(direction, dst_rect),
|
|
1433
|
+
lane_secondary,
|
|
1434
|
+
)
|
|
1386
1435
|
|
|
1387
1436
|
|
|
1388
1437
|
# --------------------------------------------------------------------------
|
|
@@ -1451,10 +1500,27 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
|
|
|
1451
1500
|
rect = rects.get(n.id)
|
|
1452
1501
|
if rect is not None:
|
|
1453
1502
|
canvas.draw_box(rect, n.shape, n.label)
|
|
1503
|
+
# Two passes across *all* edges, not one pass per edge: every
|
|
1504
|
+
# edge's line + arrowheads draw first, then every edge's label
|
|
1505
|
+
# draws last (see design doc). Interleaving line-then-label per
|
|
1506
|
+
# edge (the old order) let a later edge's line silently erase an
|
|
1507
|
+
# earlier edge's already-placed label whenever their paths shared
|
|
1508
|
+
# a cell — labels are only ever safe once no more lines are coming.
|
|
1454
1509
|
lane_counter = [0]
|
|
1455
1510
|
self_loop_counter: dict[str, int] = {}
|
|
1511
|
+
edge_paths: list[tuple[FlowEdge, list[tuple[int, int]]]] = []
|
|
1456
1512
|
for e in g.edges:
|
|
1457
|
-
|
|
1513
|
+
points = _route_edge_path(rects, g.direction, e, lane_counter, self_loop_counter)
|
|
1514
|
+
if points is not None:
|
|
1515
|
+
edge_paths.append((e, points))
|
|
1516
|
+
for e, points in edge_paths:
|
|
1517
|
+
_draw_polyline(canvas, points, e.style)
|
|
1518
|
+
_draw_arrowheads(canvas, points, e)
|
|
1519
|
+
for e, points in edge_paths:
|
|
1520
|
+
if e.label:
|
|
1521
|
+
seg = _longest_segment(points)
|
|
1522
|
+
if seg is not None:
|
|
1523
|
+
_draw_label_on_segment(canvas, *seg, e.label)
|
|
1458
1524
|
return canvas.to_lines()
|
|
1459
1525
|
except Exception:
|
|
1460
1526
|
return []
|
|
@@ -67,6 +67,33 @@ def test_inline_label_edge_shows_label_text():
|
|
|
67
67
|
assert "go now" in text
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def test_multiple_labeled_back_edges_do_not_erase_each_other():
|
|
71
|
+
# Regression: the router used to draw each edge's line+label inline in
|
|
72
|
+
# declaration order, so a later edge's line could silently overwrite an
|
|
73
|
+
# earlier edge's already-placed label. Both r1 and r2 must survive, and
|
|
74
|
+
# with real separation — not just "r1" as a bare substring of a
|
|
75
|
+
# concatenated "r2r1" run.
|
|
76
|
+
source = "graph TD\nA-->B\nB-->C\nC-->|r1|A\nC-->|r2|B\n"
|
|
77
|
+
lines = render_flowchart(source, width=80)
|
|
78
|
+
assert lines
|
|
79
|
+
text = "\n".join(lines)
|
|
80
|
+
assert "r1" in text
|
|
81
|
+
assert "r2" in text
|
|
82
|
+
assert "r1r2" not in text and "r2r1" not in text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_lr_short_edge_label_renders_in_full():
|
|
86
|
+
# Regression: the horizontal label placer used to constrain a label to
|
|
87
|
+
# the clear run between two adjacent reserved box borders, clipping
|
|
88
|
+
# "hello" down to just "el". The rank-band gap now widens to fit the
|
|
89
|
+
# label (see mermaid_flow_layout._rank_gap_overrides).
|
|
90
|
+
source = "graph LR\nA -->|hello| B\n"
|
|
91
|
+
lines = render_flowchart(source, width=80)
|
|
92
|
+
assert lines
|
|
93
|
+
text = "\n".join(lines)
|
|
94
|
+
assert "hello" in text
|
|
95
|
+
|
|
96
|
+
|
|
70
97
|
# --------------------------------------------------------------------------
|
|
71
98
|
# Labeled back-edge cycle — the exact shape that panics the Go binary
|
|
72
99
|
# --------------------------------------------------------------------------
|
|
@@ -90,6 +117,40 @@ def test_labeled_back_edge_cycle_renders_all_boxes_label_and_arrow():
|
|
|
90
117
|
assert row_a < row_b < row_c
|
|
91
118
|
|
|
92
119
|
|
|
120
|
+
# --------------------------------------------------------------------------
|
|
121
|
+
# Self-loop — a node edge back to itself
|
|
122
|
+
# --------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_self_loop_renders_visible_loop_and_arrowhead():
|
|
126
|
+
# A renderer that silently dropped self-loops would still pass a bare
|
|
127
|
+
# "non-empty, contains A and B" smoke check — assert the loop's real
|
|
128
|
+
# geometry: an arrowhead, and glyphs extending past the node's own box
|
|
129
|
+
# border (a dropped loop renders only the bare box).
|
|
130
|
+
source = "graph TD\nA-->B\nA-->A\n"
|
|
131
|
+
lines = render_flowchart(source, width=80)
|
|
132
|
+
assert lines
|
|
133
|
+
text = "\n".join(lines)
|
|
134
|
+
assert "A" in text and "B" in text
|
|
135
|
+
assert _ARROW_RE.search(text)
|
|
136
|
+
box_right_col = max(line.index("\u2510") for line in lines if "\u2510" in line)
|
|
137
|
+
assert any(len(line.rstrip()) > box_right_col + 1 for line in lines), (
|
|
138
|
+
"expected loop glyphs extending past a node box's right border"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_labeled_self_loop_renders_loop_arrowhead_and_label():
|
|
143
|
+
source = "graph TD\nA-->|again|A\n"
|
|
144
|
+
lines = render_flowchart(source, width=80)
|
|
145
|
+
assert lines
|
|
146
|
+
text = "\n".join(lines)
|
|
147
|
+
assert "A" in text
|
|
148
|
+
assert "again" in text
|
|
149
|
+
assert _ARROW_RE.search(text)
|
|
150
|
+
box_right_col = max(line.index("\u2510") for line in lines if "\u2510" in line)
|
|
151
|
+
assert any(len(line.rstrip()) > box_right_col + 1 for line in lines)
|
|
152
|
+
|
|
153
|
+
|
|
93
154
|
# --------------------------------------------------------------------------
|
|
94
155
|
# Multi-parent DAG — both edges attach to the shared child
|
|
95
156
|
# --------------------------------------------------------------------------
|
|
@@ -161,8 +222,32 @@ def test_empty_body_headed_diagram_raw_echoes():
|
|
|
161
222
|
assert not _BOX_GLYPH_RE.search(text)
|
|
162
223
|
|
|
163
224
|
|
|
164
|
-
def
|
|
165
|
-
#
|
|
225
|
+
def test_degraded_echo_sanitizes_box_glyphs_present_in_raw_source():
|
|
226
|
+
# Regression: the raw-echo path used to be a plain rstrip of the
|
|
227
|
+
# source, so a literal box-drawing/geometric glyph typed into
|
|
228
|
+
# otherwise-malformed source (e.g. a stray "\u250c") survived into the
|
|
229
|
+
# echo, which the downstream attach viewer could misdetect as a
|
|
230
|
+
# successful render. The echo must now contain NONE of those glyphs.
|
|
231
|
+
source = "not a diagram\n\u250c\n"
|
|
232
|
+
lines = render_flowchart(source, width=80)
|
|
233
|
+
text = "\n".join(lines)
|
|
234
|
+
assert not _BOX_GLYPH_RE.search(text)
|
|
235
|
+
assert lines[0] == "not a diagram"
|
|
236
|
+
assert "\u250c" not in lines[1]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_render_flowchart_never_raises_and_degrades_cleanly_on_garbage_input():
|
|
240
|
+
# Defensive: assorted odd inputs must never raise. A bare
|
|
241
|
+
# "isinstance(lines, list)" would also accept a renderer that returned
|
|
242
|
+
# `[]`, a raw echo, or a bogus boxed diagram for every case — assert
|
|
243
|
+
# the actual degradation semantics instead: whichever inputs don't
|
|
244
|
+
# render real box glyphs must be an *exact* rstripped echo of the
|
|
245
|
+
# source (the load-bearing contract), not merely "some list".
|
|
166
246
|
for source in ("", "\n\n\n", "graph", "flowchart LR\n", "graph TD\nA-->\n"):
|
|
167
247
|
lines = render_flowchart(source, width=80)
|
|
168
248
|
assert isinstance(lines, list)
|
|
249
|
+
text = "\n".join(lines)
|
|
250
|
+
if not _BOX_GLYPH_RE.search(text):
|
|
251
|
+
assert lines == [line.rstrip() for line in source.splitlines()], (
|
|
252
|
+
f"degraded output for {source!r} must be an exact raw echo, got {lines!r}"
|
|
253
|
+
)
|
|
@@ -195,12 +195,17 @@ def test_edge_style_bidirectional_golden():
|
|
|
195
195
|
|
|
196
196
|
|
|
197
197
|
def test_pipe_label_golden():
|
|
198
|
+
# The rank-band gap widens to fit the label in full (see
|
|
199
|
+
# mermaid_flow_layout._rank_gap_overrides) — a 2-char label needs a
|
|
200
|
+
# taller gap than the base _ROW_GAP alone provides.
|
|
198
201
|
assert _lines("graph TD\nA-->|hi|B\n") == [
|
|
199
202
|
"┌───┐",
|
|
200
203
|
"│ A │",
|
|
201
204
|
"└───┘",
|
|
205
|
+
" │",
|
|
202
206
|
" hi",
|
|
203
207
|
" │",
|
|
208
|
+
" │",
|
|
204
209
|
"┌─▼─┐",
|
|
205
210
|
"│ B │",
|
|
206
211
|
"└───┘",
|
|
@@ -208,12 +213,19 @@ def test_pipe_label_golden():
|
|
|
208
213
|
|
|
209
214
|
|
|
210
215
|
def test_inline_label_golden():
|
|
216
|
+
# Same rank-band widening as above, scaled to the longer "go now" label.
|
|
211
217
|
assert _lines("graph TD\nA -- go now --> B\n") == [
|
|
212
218
|
"┌───┐",
|
|
213
219
|
"│ A │",
|
|
214
220
|
"└───┘",
|
|
221
|
+
" │",
|
|
222
|
+
" │",
|
|
223
|
+
" │",
|
|
215
224
|
"go now",
|
|
216
225
|
" │",
|
|
226
|
+
" │",
|
|
227
|
+
" │",
|
|
228
|
+
" │",
|
|
217
229
|
"┌─▼─┐",
|
|
218
230
|
"│ B │",
|
|
219
231
|
"└───┘",
|
|
@@ -27,6 +27,14 @@ from termrender.renderers.mermaid_flow_model import (
|
|
|
27
27
|
)
|
|
28
28
|
|
|
29
29
|
_BOX_GLYPH_RE = re.compile(r"[\u2500-\u259F\u25A0-\u25FF]")
|
|
30
|
+
_ARROW_RE = re.compile(r"[\u25b2\u25bc\u25b6\u25c0]") # ▲▼▶◀
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _row_of(lines: list[str], label: str) -> int:
|
|
34
|
+
for i, line in enumerate(lines):
|
|
35
|
+
if label in line:
|
|
36
|
+
return i
|
|
37
|
+
raise AssertionError(f"label {label!r} not found in rendered output: {lines!r}")
|
|
30
38
|
|
|
31
39
|
|
|
32
40
|
def _node(id_: str, label: str | None = None, shape: NodeShape = NodeShape.RECT) -> FlowNode:
|
|
@@ -167,6 +175,10 @@ def test_wide_fanout_siblings_do_not_overlap():
|
|
|
167
175
|
|
|
168
176
|
|
|
169
177
|
def test_cycle_lays_out_without_hanging():
|
|
178
|
+
# Real topology, not just "didn't hang": the forward chain A -> B -> C
|
|
179
|
+
# still orders top-to-bottom despite the C -> A back-edge, and a
|
|
180
|
+
# back-edge arrowhead actually lands (somewhere) rather than being
|
|
181
|
+
# silently dropped.
|
|
170
182
|
g = FlowGraph(
|
|
171
183
|
direction=Direction.TB,
|
|
172
184
|
nodes=[_node("A"), _node("B"), _node("C")],
|
|
@@ -178,10 +190,17 @@ def test_cycle_lays_out_without_hanging():
|
|
|
178
190
|
for label in ("A", "B", "C"):
|
|
179
191
|
assert label in text
|
|
180
192
|
assert any(_BOX_GLYPH_RE.search(line) for line in lines)
|
|
193
|
+
assert _ARROW_RE.search(text)
|
|
194
|
+
assert _row_of(lines, "A") < _row_of(lines, "B") < _row_of(lines, "C")
|
|
181
195
|
|
|
182
196
|
|
|
183
197
|
def test_labeled_back_edge_cycle_does_not_crash():
|
|
184
|
-
# The exact shape that panics the Go binary: a labeled back-edge.
|
|
198
|
+
# The exact shape that panics the Go binary: a labeled back-edge. Real
|
|
199
|
+
# assertions: the "retry" label itself survives, an arrowhead lands,
|
|
200
|
+
# and the forward chain's top-to-bottom order is unaffected by the
|
|
201
|
+
# back-edge — a renderer that silently dropped the labeled back-edge
|
|
202
|
+
# (but kept the two forward edges) would otherwise still pass this test
|
|
203
|
+
# if it only checked "didn't crash".
|
|
185
204
|
g = FlowGraph(
|
|
186
205
|
direction=Direction.TB,
|
|
187
206
|
nodes=[_node("A"), _node("B"), _node("C")],
|
|
@@ -193,10 +212,19 @@ def test_labeled_back_edge_cycle_does_not_crash():
|
|
|
193
212
|
)
|
|
194
213
|
lines = layout_flowgraph(g, width=80)
|
|
195
214
|
assert lines
|
|
215
|
+
text = "\n".join(lines)
|
|
196
216
|
assert any(_BOX_GLYPH_RE.search(line) for line in lines)
|
|
217
|
+
assert "retry" in text
|
|
218
|
+
assert _ARROW_RE.search(text)
|
|
219
|
+
assert _row_of(lines, "A") < _row_of(lines, "B") < _row_of(lines, "C")
|
|
197
220
|
|
|
198
221
|
|
|
199
|
-
def
|
|
222
|
+
def test_self_loop_renders_visible_loop_and_arrowhead():
|
|
223
|
+
# A renderer that silently dropped every self-loop would still pass a
|
|
224
|
+
# bare "non-empty output containing A and B" check — assert the loop's
|
|
225
|
+
# actual geometry instead: an arrowhead, and line glyphs extending
|
|
226
|
+
# visibly past the node box's own border (a dropped self-loop renders
|
|
227
|
+
# only the bare box, whose lines never exceed its own right border).
|
|
200
228
|
g = FlowGraph(
|
|
201
229
|
direction=Direction.TB,
|
|
202
230
|
nodes=[_node("A"), _node("B")],
|
|
@@ -206,6 +234,27 @@ def test_self_loop_does_not_crash():
|
|
|
206
234
|
assert lines
|
|
207
235
|
text = "\n".join(lines)
|
|
208
236
|
assert "A" in text and "B" in text
|
|
237
|
+
assert _ARROW_RE.search(text)
|
|
238
|
+
box_right_col = max(line.index("\u2510") for line in lines if "\u2510" in line)
|
|
239
|
+
assert any(len(line.rstrip()) > box_right_col + 1 for line in lines), (
|
|
240
|
+
"expected loop glyphs extending past a node box's right border"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_labeled_self_loop_renders_loop_arrowhead_and_label():
|
|
245
|
+
g = FlowGraph(
|
|
246
|
+
direction=Direction.TB,
|
|
247
|
+
nodes=[_node("A")],
|
|
248
|
+
edges=[_edge("A", "A", label="again")],
|
|
249
|
+
)
|
|
250
|
+
lines = layout_flowgraph(g, width=80)
|
|
251
|
+
assert lines
|
|
252
|
+
text = "\n".join(lines)
|
|
253
|
+
assert "A" in text
|
|
254
|
+
assert "again" in text
|
|
255
|
+
assert _ARROW_RE.search(text)
|
|
256
|
+
box_right_col = max(line.index("\u2510") for line in lines if "\u2510" in line)
|
|
257
|
+
assert any(len(line.rstrip()) > box_right_col + 1 for line in lines)
|
|
209
258
|
|
|
210
259
|
|
|
211
260
|
# --------------------------------------------------------------------------
|
|
@@ -214,7 +263,11 @@ def test_self_loop_does_not_crash():
|
|
|
214
263
|
|
|
215
264
|
|
|
216
265
|
@pytest.mark.parametrize("direction", [Direction.TB, Direction.LR, Direction.RL, Direction.BT])
|
|
217
|
-
def
|
|
266
|
+
def test_all_directions_place_nodes_along_the_correct_axis(direction):
|
|
267
|
+
# A direction-blind renderer (e.g. one that ignored LR/RL/BT and always
|
|
268
|
+
# laid out top-to-bottom) would still pass a bare "doesn't crash, all
|
|
269
|
+
# labels present" check for every direction — assert each direction's
|
|
270
|
+
# actual rank-flow axis and sign instead.
|
|
218
271
|
g = FlowGraph(
|
|
219
272
|
direction=direction,
|
|
220
273
|
nodes=[_node("A"), _node("B"), _node("C")],
|
|
@@ -222,11 +275,27 @@ def test_all_directions_render_without_crashing(direction):
|
|
|
222
275
|
)
|
|
223
276
|
lines = layout_flowgraph(g, width=80)
|
|
224
277
|
assert lines
|
|
225
|
-
text = "\n".join(lines)
|
|
226
|
-
for label in ("A", "B", "C"):
|
|
227
|
-
assert label in text
|
|
228
278
|
assert any(_BOX_GLYPH_RE.search(line) for line in lines)
|
|
229
279
|
|
|
280
|
+
row: dict[str, int] = {}
|
|
281
|
+
col: dict[str, int] = {}
|
|
282
|
+
for label in ("A", "B", "C"):
|
|
283
|
+
for i, line in enumerate(lines):
|
|
284
|
+
if label in line:
|
|
285
|
+
row[label] = i
|
|
286
|
+
col[label] = line.index(label)
|
|
287
|
+
break
|
|
288
|
+
assert label in row, f"label {label!r} not found in rendered output: {lines!r}"
|
|
289
|
+
|
|
290
|
+
if direction is Direction.TB:
|
|
291
|
+
assert row["A"] < row["B"] < row["C"]
|
|
292
|
+
elif direction is Direction.BT:
|
|
293
|
+
assert row["A"] > row["B"] > row["C"]
|
|
294
|
+
elif direction is Direction.LR:
|
|
295
|
+
assert col["A"] < col["B"] < col["C"]
|
|
296
|
+
else: # Direction.RL
|
|
297
|
+
assert col["A"] > col["B"] > col["C"]
|
|
298
|
+
|
|
230
299
|
|
|
231
300
|
def test_lr_direction_no_overlap():
|
|
232
301
|
from termrender.renderers.mermaid_flow_layout import _place_nodes
|
|
@@ -278,18 +347,32 @@ def test_fully_isolated_nodes_no_edges():
|
|
|
278
347
|
|
|
279
348
|
|
|
280
349
|
def test_edges_draw_visible_line_glyphs_between_boxes():
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
350
|
+
# The old version of this test accepted plain box-border glyphs
|
|
351
|
+
# (┌┐└┘) in its allowed set, so it passed even for a graph with NO
|
|
352
|
+
# edge at all (the two node boxes alone contain those glyphs). Tighten
|
|
353
|
+
# by inspecting the *inter-box gap rows* specifically — guaranteed
|
|
354
|
+
# clear of any box by construction (the layout enforces a non-zero
|
|
355
|
+
# rank gap) — and require a routed line/arrow glyph there; a deleted
|
|
356
|
+
# edge router would leave those rows blank.
|
|
357
|
+
from termrender.renderers.mermaid_flow_layout import _place_nodes
|
|
358
|
+
|
|
359
|
+
nodes = [_node("A"), _node("B")]
|
|
360
|
+
edges = [_edge("A", "B")]
|
|
361
|
+
rects = _place_nodes(nodes, edges, Direction.TB)
|
|
362
|
+
a, b = rects["A"], rects["B"]
|
|
363
|
+
gap_lo, gap_hi = a.y + a.h, b.y
|
|
364
|
+
assert gap_hi > gap_lo, "expected a real inter-rank gap between the two boxes"
|
|
365
|
+
|
|
366
|
+
g = FlowGraph(direction=Direction.TB, nodes=nodes, edges=edges)
|
|
286
367
|
lines = layout_flowgraph(g, width=80)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
# between the two boxes (not just the box borders themselves).
|
|
368
|
+
gap_rows = lines[gap_lo:gap_hi]
|
|
369
|
+
assert gap_rows, "expected rendered rows in the inter-box gap"
|
|
290
370
|
line_glyphs = {"\u2502", "\u2500", "\u250c", "\u2510", "\u2514", "\u2518",
|
|
291
|
-
"\u251c", "\u2524", "\u252c", "\u2534", "\u253c"
|
|
292
|
-
|
|
371
|
+
"\u251c", "\u2524", "\u252c", "\u2534", "\u253c",
|
|
372
|
+
"\u25b2", "\u25bc", "\u25b6", "\u25c0"}
|
|
373
|
+
assert any(ch in row for row in gap_rows for ch in line_glyphs), (
|
|
374
|
+
f"expected a routed edge glyph strictly between the two boxes, got {gap_rows!r}"
|
|
375
|
+
)
|
|
293
376
|
|
|
294
377
|
|
|
295
378
|
def test_dangling_edge_reference_is_ignored_not_raised():
|
|
@@ -375,8 +458,12 @@ def test_boxrect_anchor_points():
|
|
|
375
458
|
# --------------------------------------------------------------------------
|
|
376
459
|
|
|
377
460
|
|
|
378
|
-
def
|
|
379
|
-
# Empty label, width-1 canvases, duplicate edges
|
|
461
|
+
def test_duplicate_edges_and_empty_labels_render_without_raising():
|
|
462
|
+
# Empty label, width-1 canvases, duplicate edges must never raise —
|
|
463
|
+
# and, since this is otherwise a perfectly well-typed two-node graph,
|
|
464
|
+
# it should still render its two real boxes rather than silently
|
|
465
|
+
# degrading (a bare "isinstance(lines, list)" would also accept an
|
|
466
|
+
# empty-list degradation or a raised-then-caught garbage result).
|
|
380
467
|
g = FlowGraph(
|
|
381
468
|
direction=Direction.TB,
|
|
382
469
|
nodes=[_node("A", label=""), _node("B", label="")],
|
|
@@ -384,3 +471,6 @@ def test_never_raises_on_odd_but_well_typed_input():
|
|
|
384
471
|
)
|
|
385
472
|
lines = layout_flowgraph(g, width=1)
|
|
386
473
|
assert isinstance(lines, list)
|
|
474
|
+
assert lines, "a well-typed non-empty graph must still render"
|
|
475
|
+
text = "\n".join(lines)
|
|
476
|
+
assert text.count("\u250c") == 2, "expected exactly the two declared node boxes"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|