termrender 4.4.0__tar.gz → 4.5.0__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.4.0 → termrender-4.5.0}/CHANGELOG.md +22 -0
- {termrender-4.4.0 → termrender-4.5.0}/PKG-INFO +1 -1
- termrender-4.5.0/src/termrender/renderers/mermaid_flow.py +110 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_flow_layout.py +378 -28
- termrender-4.5.0/tests/test_mermaid_flow.py +168 -0
- {termrender-4.4.0 → termrender-4.5.0}/.github/workflows/publish.yml +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/.gitignore +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/CLAUDE.md +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/LICENSE +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/README.md +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/design.json +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/pyproject.toml +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/requirements.json +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/__init__.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/__main__.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/blocks.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/emit.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/layout.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/parser.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/py.typed +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/code.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/table.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/text.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/src/termrender/style.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/__init__.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_charts.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_cli_contract.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_column_alignment.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_diff.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_inline_badge.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_linebreak.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_flow_layout.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_flow_parser.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_myst_gaps.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_stat.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_tasklist.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_timeline.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/tests/test_variable_colons.py +0 -0
- {termrender-4.4.0 → termrender-4.5.0}/uv.lock +0 -0
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.5.0 (2026-07-06)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- Add flowchart edge router and public render_flowchart
|
|
9
|
+
([`a1b46cf`](https://github.com/crouton-labs/termrender/commit/a1b46cf11607ec6b31ea8fe1b4d90b11aafff10f))
|
|
10
|
+
|
|
11
|
+
- Replace the placeholder edge drawing in mermaid_flow_layout.py with the full orthogonal router:
|
|
12
|
+
rank-relative endpoint anchors (forward/same-rank/ back-edge), L/Z staircase paths for forward
|
|
13
|
+
edges, growing side-lane C-paths for back-edges so they never overlay forward edges, arrowheads
|
|
14
|
+
chosen from final-segment direction, edge labels centered on the longest straight run (shifting
|
|
15
|
+
off reserved cells), and self-loops modeled on the sequence renderer's self-message loop. - Add
|
|
16
|
+
mermaid_flow.py exposing the public, pure, never-raising render_flowchart(source, width) ->
|
|
17
|
+
list[str], wiring parse -> layout_flowgraph -> lines behind the three-case degradation contract
|
|
18
|
+
(no header, zero nodes, unexpected layout exception -> raw echo). - Add
|
|
19
|
+
tests/test_mermaid_flow.py: real end-to-end geometry/topology assertions, including the labeled
|
|
20
|
+
back-edge cycle that panics the Go binary, multi-parent attachment, headless edges, and echo
|
|
21
|
+
degradation.
|
|
22
|
+
|
|
23
|
+
Unwired: nothing imports mermaid_flow yet (later phase's cutover).
|
|
24
|
+
|
|
25
|
+
|
|
4
26
|
## v4.4.0 (2026-07-06)
|
|
5
27
|
|
|
6
28
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.5.0
|
|
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
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Public entry point for the native mermaid flowchart/graph renderer.
|
|
2
|
+
|
|
3
|
+
Standalone module: exposes a single pure function, :func:`render_flowchart`.
|
|
4
|
+
Not yet wired into ``mermaid.py``'s dispatcher (a later, orchestrator-owned
|
|
5
|
+
phase) — nothing in this package imports it. This module is a thin
|
|
6
|
+
orchestrator over the parser and layout engine; it owns none of the grammar
|
|
7
|
+
or layout logic itself, only header sniffing and the degradation contract.
|
|
8
|
+
|
|
9
|
+
Layout model
|
|
10
|
+
------------
|
|
11
|
+
``render_flowchart`` is ``parse -> layout_flowgraph -> lines``, wrapped in a
|
|
12
|
+
degradation guard (see *Degradation contract* below). :func:`~termrender.
|
|
13
|
+
renderers.mermaid_flow_parser.parse` turns mermaid source into a
|
|
14
|
+
:class:`~termrender.renderers.mermaid_flow_model.FlowGraph`;
|
|
15
|
+
:func:`~termrender.renderers.mermaid_flow_layout.layout_flowgraph` lays it
|
|
16
|
+
out with ``grandalf`` (pure-Python Sugiyama) and rasterizes it — bordered
|
|
17
|
+
rectangle nodes, an orthogonal edge router (L/Z paths for forward edges,
|
|
18
|
+
growing side lanes for back-edges/cycles, arrowheads, edge labels,
|
|
19
|
+
self-loops) — onto a char grid. See ``mermaid_flow_parser.py`` and
|
|
20
|
+
``mermaid_flow_layout.py``'s module docstrings for the full grammar and
|
|
21
|
+
routing model; this module covers only the public contract.
|
|
22
|
+
|
|
23
|
+
Grammar supported
|
|
24
|
+
------------------
|
|
25
|
+
Header: ``graph``/``flowchart`` + direction (``TD``/``TB``/``LR``/``RL``/
|
|
26
|
+
``BT``). Node shapes: ``[rect]``, ``(round)``, ``([stadium])``,
|
|
27
|
+
``[(cylinder)]``, ``((circle))``, ``{diamond}``, ``{{hexagon}}``,
|
|
28
|
+
``[[subroutine]]``, ``[/parallelogram/]`` — this phase renders every shape
|
|
29
|
+
as a plain rectangle (distinct shape borders are a later phase; only the
|
|
30
|
+
label text is load-bearing here). Edges: ``-->``, ``---``, ``-.->``, ``==>``
|
|
31
|
+
(and bidirectional forms), with ``|label|`` and inline ``A -- text --> B``
|
|
32
|
+
labels, and ``&`` fan-out. ``subgraph ... end`` blocks parse but are not yet
|
|
33
|
+
rendered as frames (nodes inside them still place and render normally).
|
|
34
|
+
``class``/``classDef``/``style``/``click``/``linkStyle`` lines are ignored.
|
|
35
|
+
|
|
36
|
+
Known degradations (by design, not bugs)
|
|
37
|
+
-----------------------------------------
|
|
38
|
+
- Node shapes other than a plain rectangle border are not yet visually
|
|
39
|
+
distinct (Phase 4 of this renderer's build).
|
|
40
|
+
- Subgraph blocks parse but are not yet drawn as enclosing frames (Phase 4).
|
|
41
|
+
- See ``mermaid_flow_layout.py``'s module docstring for the edge-routing
|
|
42
|
+
degradations (dense-graph crossings, label-lane overlap, CJK wrapping,
|
|
43
|
+
minimum-box-size self-loop cosmetics).
|
|
44
|
+
- ``width`` is advisory only: like the other native renderers in this
|
|
45
|
+
package, this renderer sizes to content and may overflow a narrow
|
|
46
|
+
terminal rather than truncate the diagram.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
from termrender.renderers.mermaid_flow_layout import layout_flowgraph
|
|
52
|
+
from termrender.renderers.mermaid_flow_parser import FlowchartError, parse
|
|
53
|
+
|
|
54
|
+
__all__ = ["render_flowchart"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_flowchart(source: str, width: int) -> list[str]:
|
|
58
|
+
"""Render a mermaid ``graph``/``flowchart`` source to unicode lines.
|
|
59
|
+
|
|
60
|
+
Pure and total: never raises. Degrades to a raw echo of ``source``
|
|
61
|
+
(each line ``\\n``-split and ``.rstrip()``-ed, containing no
|
|
62
|
+
box-drawing glyphs) exactly when:
|
|
63
|
+
|
|
64
|
+
1. The source isn't a flowchart at all (no ``graph``/``flowchart``
|
|
65
|
+
header on its first non-blank, post-prelude line) — :func:`parse`
|
|
66
|
+
raises :class:`FlowchartError`.
|
|
67
|
+
2. Parsing succeeds but yields zero nodes (an empty or comment-only
|
|
68
|
+
diagram body) — nothing to draw.
|
|
69
|
+
3. Any unexpected exception escapes :func:`layout_flowgraph` — a
|
|
70
|
+
defensive catch-all; this should never happen for well-typed input,
|
|
71
|
+
but the guarantee is "never crash, degrade" regardless.
|
|
72
|
+
|
|
73
|
+
Otherwise, returns the rendered diagram: guaranteed to contain unicode
|
|
74
|
+
box-drawing glyphs (every node is a bordered box), never ANSI escapes.
|
|
75
|
+
The distinction matters downstream — code that detects render success
|
|
76
|
+
by the *presence* of box-drawing glyphs relies on the echo path never
|
|
77
|
+
emitting any.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
source: The mermaid fence body (with or without the surrounding
|
|
81
|
+
fence markers — only the text between them).
|
|
82
|
+
width: Advisory terminal width. See the module docstring's
|
|
83
|
+
"Known degradations" for why this is advisory rather than a
|
|
84
|
+
hard wrap.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Rendered lines on success, or a raw-echo of ``source`` on any of
|
|
88
|
+
the three degradation conditions above.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
graph = parse(source)
|
|
92
|
+
except FlowchartError:
|
|
93
|
+
return _raw_echo(source)
|
|
94
|
+
|
|
95
|
+
if not graph.nodes:
|
|
96
|
+
return _raw_echo(source)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
lines = layout_flowgraph(graph, width)
|
|
100
|
+
except Exception:
|
|
101
|
+
return _raw_echo(source)
|
|
102
|
+
|
|
103
|
+
if not lines:
|
|
104
|
+
return _raw_echo(source)
|
|
105
|
+
|
|
106
|
+
return lines
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _raw_echo(source: str) -> list[str]:
|
|
110
|
+
return [line.rstrip() for line in source.splitlines()]
|
|
@@ -42,18 +42,39 @@ direction. A cyclic :class:`FlowGraph` (e.g. ``A->B->C->A``) lays out and
|
|
|
42
42
|
renders without any manual cycle-breaking — confirmed empirically against
|
|
43
43
|
grandalf 0.8 (see the design doc's "grandalf adapter recipe" section).
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
Edge routing
|
|
46
|
+
------------
|
|
47
47
|
This module renders every node as a bordered **rectangle** regardless of its
|
|
48
48
|
declared :class:`NodeShape` — distinct shape borders (diamond, round,
|
|
49
|
-
stadium, circle, ...)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
the full orthogonal router
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
:
|
|
49
|
+
stadium, circle, ...) and subgraph frames (:meth:`Canvas.draw_frame` exists
|
|
50
|
+
but :func:`layout_flowgraph` does not yet call it) are a later phase.
|
|
51
|
+
|
|
52
|
+
Edges get the full orthogonal router. Per edge, endpoint anchors are chosen
|
|
53
|
+
by the two boxes' *relative rank position* (their spans along the rank axis
|
|
54
|
+
either overlap — same rank — or one is strictly ahead of the other, a test
|
|
55
|
+
that holds regardless of direction because rank bands are always separated
|
|
56
|
+
by a clear gap): a forward edge exits/enters the border facing the rank-flow
|
|
57
|
+
direction (``bottom_mid``/``top_mid`` for TB, mirrored/transposed for
|
|
58
|
+
BT/LR/RL); a same-rank edge uses the two boxes' facing side mids; a back-edge
|
|
59
|
+
(destination at an earlier rank — the cycle case) exits/enters the side
|
|
60
|
+
perpendicular to the rank axis and routes through a growing side lane so
|
|
61
|
+
stacked back-edges never collide (the right side for TB/BT, the bottom side
|
|
62
|
+
for LR/RL, per the axis swap). Forward paths are a single straight run when
|
|
63
|
+
the two anchors already share a column/row, else a Z/staircase through the
|
|
64
|
+
midpoint of the clear inter-rank band. Arrowheads (``▼▲▶◀``) are chosen from
|
|
65
|
+
the final segment's direction of travel and overwrite the border cell they
|
|
66
|
+
land on. Edge labels center on the path's longest straight run, shifting
|
|
67
|
+
along it to the nearest cell span clear of any box if the ideal midpoint
|
|
68
|
+
lands on one. Self-loops (``src == dst``, excluded from the grandalf graph)
|
|
69
|
+
draw a small loop off the same side used for back-edge lanes, stacking
|
|
70
|
+
outward per repeated self-loop on one node.
|
|
71
|
+
|
|
72
|
+
The router draws only axis-aligned L/Z/C paths and does no global crossing
|
|
73
|
+
minimization or obstacle avoidance — a path that would cross another box
|
|
74
|
+
simply has those cells skipped by :meth:`Canvas.draw_segment` (reserved
|
|
75
|
+
cells are never overwritten by a line). This matches the medium's ceiling
|
|
76
|
+
for small (≤~20 node) agent-emitted graphs; see the design doc's
|
|
77
|
+
"Orthogonal edge routing" section for the full rationale.
|
|
57
78
|
|
|
58
79
|
Known degradations (by design, not bugs)
|
|
59
80
|
-----------------------------------------
|
|
@@ -62,8 +83,13 @@ Known degradations (by design, not bugs)
|
|
|
62
83
|
root ``CLAUDE.md``) — CJK/wide-glyph labels may wrap at the wrong point.
|
|
63
84
|
Box *dimensions*, however, are always computed from :func:`visual_len` of
|
|
64
85
|
the wrapped lines, so the box itself is never too narrow for its content.
|
|
65
|
-
- Dense graphs may show edge-line crossings and
|
|
66
|
-
|
|
86
|
+
- Dense graphs may show edge-line crossings, and two edge labels sharing a
|
|
87
|
+
crowded lane may overlap each other even after the nearest-clear-run
|
|
88
|
+
shift — an accepted limit of the medium, not a routing bug.
|
|
89
|
+
- A self-loop or back-edge on/into a minimum-size box (``_MIN_BOX_W``/
|
|
90
|
+
``_MIN_BOX_H``) may land its arrowhead on a corner glyph rather than a
|
|
91
|
+
straight border cell, since a 1-interior-row/column box has no other
|
|
92
|
+
distinct anchor point to use — cosmetic only.
|
|
67
93
|
- An edge referencing a node id absent from the graph (a malformed/hand-built
|
|
68
94
|
:class:`FlowGraph`) is silently skipped rather than raised — the parser is
|
|
69
95
|
expected to guarantee valid endpoints, but this module never crashes on a
|
|
@@ -521,12 +547,33 @@ def _place_nodes(
|
|
|
521
547
|
|
|
522
548
|
|
|
523
549
|
# --------------------------------------------------------------------------
|
|
524
|
-
# --- router
|
|
550
|
+
# --- router ---
|
|
525
551
|
# --------------------------------------------------------------------------
|
|
526
552
|
|
|
553
|
+
# The full orthogonal router. Endpoint anchors are chosen from each edge's
|
|
554
|
+
# *relative rank position* (forward / same-rank / back-edge); forward edges
|
|
555
|
+
# route a straight or Z-shaped (staircase) path through the clear inter-rank
|
|
556
|
+
# band, same-rank edges route between facing side mids, and back-edges route
|
|
557
|
+
# through a growing side lane so they never overlay forward edges. See the
|
|
558
|
+
# design doc's "Orthogonal edge routing" section for the full rationale —
|
|
559
|
+
# this module implements it without re-deciding any of it.
|
|
560
|
+
|
|
561
|
+
_LANE_GAP = 2 # spacing between stacked back-edge side lanes
|
|
562
|
+
_LANE_MARGIN = 2 # gap from the involved boxes' far edge to the first lane
|
|
563
|
+
_LOOP_REACH = 3 # base horizontal/vertical extent of a self-loop
|
|
564
|
+
|
|
565
|
+
_ARROW_GLYPHS: dict[tuple[int, int], str] = {
|
|
566
|
+
(1, 0): "\u25b6", # ▶
|
|
567
|
+
(-1, 0): "\u25c0", # ◀
|
|
568
|
+
(0, 1): "\u25bc", # ▼
|
|
569
|
+
(0, -1): "\u25b2", # ▲
|
|
570
|
+
}
|
|
571
|
+
|
|
527
572
|
|
|
528
573
|
def _facing_anchor(this: BoxRect, other: BoxRect) -> tuple[int, int]:
|
|
529
|
-
"""Pick the border midpoint of ``this`` box facing toward ``other
|
|
574
|
+
"""Pick the border midpoint of ``this`` box facing toward ``other`` —
|
|
575
|
+
used for same-rank edges, where neither box is "ahead" of the other
|
|
576
|
+
along the rank axis."""
|
|
530
577
|
tcx, tcy = this.center
|
|
531
578
|
ocx, ocy = other.center
|
|
532
579
|
dx, dy = ocx - tcx, ocy - tcy
|
|
@@ -535,27 +582,328 @@ def _facing_anchor(this: BoxRect, other: BoxRect) -> tuple[int, int]:
|
|
|
535
582
|
return this.right_mid if dx >= 0 else this.left_mid
|
|
536
583
|
|
|
537
584
|
|
|
538
|
-
def
|
|
539
|
-
"""
|
|
585
|
+
def _arrow_glyph(frm: tuple[int, int], to: tuple[int, int]) -> str:
|
|
586
|
+
"""``▼▲▶◀`` for the direction of travel from ``frm`` to ``to`` — the
|
|
587
|
+
glyph an arrowhead landing at ``to`` should show."""
|
|
588
|
+
dx, dy = to[0] - frm[0], to[1] - frm[1]
|
|
589
|
+
if dx == 0 and dy == 0:
|
|
590
|
+
return _ARROW_GLYPHS[(0, 1)]
|
|
591
|
+
if abs(dx) >= abs(dy):
|
|
592
|
+
return _ARROW_GLYPHS[(1 if dx > 0 else -1, 0)]
|
|
593
|
+
return _ARROW_GLYPHS[(0, 1 if dy > 0 else -1)]
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _rank_is_horizontal(direction: Direction) -> bool:
|
|
597
|
+
"""True when the rank-flow axis is x (LR/RL) rather than y (TB/BT)."""
|
|
598
|
+
return direction in (Direction.LR, Direction.RL)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _forward_increasing(direction: Direction) -> bool:
|
|
602
|
+
"""True when rank number increases with increasing coordinate along the
|
|
603
|
+
rank axis (TB: increasing y; LR: increasing x) — false for BT/RL, whose
|
|
604
|
+
adapter coordinate transform mirrors that axis (see module docstring)."""
|
|
605
|
+
return direction in (Direction.TB, Direction.LR)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _rank_extent(direction: Direction, rect: BoxRect) -> tuple[int, int]:
|
|
609
|
+
"""``rect``'s [min, max] span along the rank axis. Two boxes in the same
|
|
610
|
+
rank always have overlapping spans (the adapter places a whole rank in
|
|
611
|
+
one top/bottom-aligned band); boxes in different ranks never do, since
|
|
612
|
+
``_ROW_GAP``/``_GAP_X`` guarantee a clear gap between bands — so span
|
|
613
|
+
overlap is a direction-agnostic same-rank test."""
|
|
614
|
+
if _rank_is_horizontal(direction):
|
|
615
|
+
return (rect.x, rect.x + rect.w - 1)
|
|
616
|
+
return (rect.y, rect.y + rect.h - 1)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _forward_exit(direction: Direction, rect: BoxRect) -> tuple[int, int]:
|
|
620
|
+
"""``rect``'s border anchor facing *downstream* along the rank axis."""
|
|
621
|
+
horiz = _rank_is_horizontal(direction)
|
|
622
|
+
forward = _forward_increasing(direction)
|
|
623
|
+
if horiz:
|
|
624
|
+
return rect.right_mid if forward else rect.left_mid
|
|
625
|
+
return rect.bottom_mid if forward else rect.top_mid
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _forward_entry(direction: Direction, rect: BoxRect) -> tuple[int, int]:
|
|
629
|
+
"""``rect``'s border anchor facing *upstream* — the mirror image of
|
|
630
|
+
:func:`_forward_exit`, used on the destination side of a forward edge."""
|
|
631
|
+
horiz = _rank_is_horizontal(direction)
|
|
632
|
+
forward = _forward_increasing(direction)
|
|
633
|
+
if horiz:
|
|
634
|
+
return rect.left_mid if forward else rect.right_mid
|
|
635
|
+
return rect.top_mid if forward else rect.bottom_mid
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _lane_anchor(direction: Direction, rect: BoxRect) -> tuple[int, int]:
|
|
639
|
+
"""``rect``'s border anchor on the side used for back-edge side lanes
|
|
640
|
+
and self-loops: the side perpendicular to the rank axis and away from
|
|
641
|
+
it — the right side for TB/BT, the bottom side for LR/RL ("the right
|
|
642
|
+
lane becomes the bottom lane" after LR/RL's axis swap)."""
|
|
643
|
+
return rect.bottom_mid if _rank_is_horizontal(direction) else rect.right_mid
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _abstract(direction: Direction, point: tuple[int, int]) -> tuple[int, int]:
|
|
647
|
+
"""``point`` decomposed into (primary, secondary) = (rank-axis coord,
|
|
648
|
+
off-axis coord) — the coordinate frame the path-shape helpers reason
|
|
649
|
+
in, direction-agnostically. Inverse: :func:`_real`."""
|
|
650
|
+
x, y = point
|
|
651
|
+
return (x, y) if _rank_is_horizontal(direction) else (y, x)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _real(direction: Direction, primary: int, secondary: int) -> tuple[int, int]:
|
|
655
|
+
"""Inverse of :func:`_abstract`: (primary, secondary) back to real
|
|
656
|
+
``(x, y)`` canvas coordinates."""
|
|
657
|
+
return (primary, secondary) if _rank_is_horizontal(direction) else (secondary, primary)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _z_path(
|
|
661
|
+
direction: Direction, exit_pt: tuple[int, int], entry_pt: tuple[int, int]
|
|
662
|
+
) -> list[tuple[int, int]]:
|
|
663
|
+
"""Forward-edge path: a single straight run when both anchors share the
|
|
664
|
+
off-axis coordinate, else a Z/staircase — out of ``exit_pt``, across at
|
|
665
|
+
the midpoint between the two ranks' facing borders, into ``entry_pt``."""
|
|
666
|
+
p0, s0 = _abstract(direction, exit_pt)
|
|
667
|
+
p1, s1 = _abstract(direction, entry_pt)
|
|
668
|
+
if s0 == s1:
|
|
669
|
+
return [exit_pt, entry_pt]
|
|
670
|
+
mid_p = (p0 + p1) // 2
|
|
671
|
+
return [exit_pt, _real(direction, mid_p, s0), _real(direction, mid_p, s1), entry_pt]
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _lane_path(
|
|
675
|
+
direction: Direction,
|
|
676
|
+
exit_pt: tuple[int, int],
|
|
677
|
+
entry_pt: tuple[int, int],
|
|
678
|
+
lane_secondary: int,
|
|
679
|
+
) -> list[tuple[int, int]]:
|
|
680
|
+
"""Back-edge C-path: out of ``exit_pt`` to the side lane, along the lane,
|
|
681
|
+
back into ``entry_pt`` — never overlaying the forward Z-paths, which
|
|
682
|
+
stay within the involved boxes' own span on this axis."""
|
|
683
|
+
p0, s0 = _abstract(direction, exit_pt)
|
|
684
|
+
p1, s1 = _abstract(direction, entry_pt)
|
|
685
|
+
del s0, s1
|
|
686
|
+
return [
|
|
687
|
+
exit_pt,
|
|
688
|
+
_real(direction, p0, lane_secondary),
|
|
689
|
+
_real(direction, p1, lane_secondary),
|
|
690
|
+
entry_pt,
|
|
691
|
+
]
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _lane_secondary_base(direction: Direction, a: BoxRect, b: BoxRect) -> int:
|
|
695
|
+
"""Starting side-lane coordinate just past the two involved boxes' far
|
|
696
|
+
edge on the off-axis — the base that :func:`_route_edge` adds
|
|
697
|
+
``_LANE_MARGIN + lane * _LANE_GAP`` to, per back-edge, to avoid stacking
|
|
698
|
+
lanes."""
|
|
699
|
+
if _rank_is_horizontal(direction):
|
|
700
|
+
return max(a.y + a.h - 1, b.y + b.h - 1)
|
|
701
|
+
return max(a.x + a.w - 1, b.x + b.w - 1)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _segment_length(a: tuple[int, int], b: tuple[int, int]) -> int:
|
|
705
|
+
return abs(a[0] - b[0]) + abs(a[1] - b[1])
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _longest_segment(
|
|
709
|
+
points: list[tuple[int, int]],
|
|
710
|
+
) -> tuple[tuple[int, int], tuple[int, int]] | None:
|
|
711
|
+
"""The longest straight run in a polyline — where an edge label is
|
|
712
|
+
centered."""
|
|
713
|
+
segments = list(zip(points, points[1:]))
|
|
714
|
+
if not segments:
|
|
715
|
+
return None
|
|
716
|
+
return max(segments, key=lambda seg: _segment_length(*seg))
|
|
717
|
+
|
|
540
718
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
719
|
+
def _label_positions(length: int, center: int, lo: int, hi: int) -> list[int]:
|
|
720
|
+
"""Candidate run-start offsets within ``[lo, hi]``, nearest-``center``
|
|
721
|
+
first — how a label shifts off a reserved cell to the nearest clear
|
|
722
|
+
run along its segment."""
|
|
723
|
+
span = max(hi - lo + 1, 1)
|
|
724
|
+
if hi - lo + 1 >= length:
|
|
725
|
+
ideal = max(lo, min(center - length // 2, hi - length + 1))
|
|
726
|
+
else:
|
|
727
|
+
ideal = lo
|
|
728
|
+
order = [ideal]
|
|
729
|
+
seen = {ideal}
|
|
730
|
+
for d in range(1, span + 1):
|
|
731
|
+
for cand in (ideal + d, ideal - d):
|
|
732
|
+
if cand not in seen:
|
|
733
|
+
order.append(cand)
|
|
734
|
+
seen.add(cand)
|
|
735
|
+
return order
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _draw_label_on_segment(
|
|
739
|
+
canvas: Canvas, a: tuple[int, int], b: tuple[int, int], label: str
|
|
740
|
+
) -> None:
|
|
741
|
+
"""Center ``label`` on the straight run ``a``-``b``. A label always
|
|
742
|
+
*reads* horizontally (measured with :func:`visual_len`): on a
|
|
743
|
+
horizontal segment it centers along the segment's own x-span at the
|
|
744
|
+
segment's row; on a vertical segment (e.g. a back-edge's side lane) it
|
|
745
|
+
still reads horizontally, centered on the segment's column, at a row
|
|
746
|
+
chosen from the segment's y-span — matching the sequence renderer's
|
|
747
|
+
self-loop label placement. Shifts along the search axis to the nearest
|
|
748
|
+
span/row fully clear of a reserved (box) cell; if none exists, writes
|
|
749
|
+
at the ideal placement anyway (overflow tolerated) but skips individual
|
|
750
|
+
reserved cells — a label never corrupts a box.
|
|
545
751
|
"""
|
|
546
|
-
|
|
752
|
+
length = visual_len(label)
|
|
753
|
+
if length <= 0:
|
|
754
|
+
return
|
|
755
|
+
(x0, y0), (x1, y1) = a, b
|
|
756
|
+
|
|
757
|
+
if y0 == y1:
|
|
758
|
+
# Horizontal segment: search along x for a clear run at row y0.
|
|
759
|
+
lo, hi = (x0, x1) if x0 <= x1 else (x1, x0)
|
|
760
|
+
row = y0
|
|
761
|
+
center = (lo + hi) // 2
|
|
762
|
+
|
|
763
|
+
def cells(start: int) -> list[tuple[int, int]]:
|
|
764
|
+
return [(start + i, row) for i in range(length)]
|
|
765
|
+
|
|
766
|
+
for start in _label_positions(length, center, lo, hi):
|
|
767
|
+
run = cells(start)
|
|
768
|
+
if not any(canvas.is_reserved(x, y) for x, y in run):
|
|
769
|
+
for (px, py), ch in zip(run, label):
|
|
770
|
+
canvas.draw_glyph(px, py, ch)
|
|
771
|
+
return
|
|
772
|
+
start = max(lo, min(center - length // 2, hi - length + 1))
|
|
773
|
+
for (px, py), ch in zip(cells(start), label):
|
|
774
|
+
if not canvas.is_reserved(px, py):
|
|
775
|
+
canvas.draw_glyph(px, py, ch)
|
|
547
776
|
return
|
|
777
|
+
|
|
778
|
+
# Vertical segment: the label still reads horizontally, centered on
|
|
779
|
+
# column x0; search along y for a row whose horizontal run is clear.
|
|
780
|
+
lo, hi = (y0, y1) if y0 <= y1 else (y1, y0)
|
|
781
|
+
col = x0
|
|
782
|
+
# Clamp rather than let the run go negative: a negative x is simply
|
|
783
|
+
# unaddressable (Canvas has no left margin to grow into), which would
|
|
784
|
+
# silently drop the label's leading characters instead of overflowing
|
|
785
|
+
# visibly to the right like every other overflow case in this module.
|
|
786
|
+
start_x = max(0, col - length // 2)
|
|
787
|
+
|
|
788
|
+
def row_cells(row: int) -> list[tuple[int, int]]:
|
|
789
|
+
return [(start_x + i, row) for i in range(length)]
|
|
790
|
+
|
|
791
|
+
center_row = (lo + hi) // 2
|
|
792
|
+
for row in _label_positions(1, center_row, lo, hi):
|
|
793
|
+
run = row_cells(row)
|
|
794
|
+
if not any(canvas.is_reserved(x, y) for x, y in run):
|
|
795
|
+
for (px, py), ch in zip(run, label):
|
|
796
|
+
canvas.draw_glyph(px, py, ch)
|
|
797
|
+
return
|
|
798
|
+
for (px, py), ch in zip(row_cells(center_row), label):
|
|
799
|
+
if not canvas.is_reserved(px, py):
|
|
800
|
+
canvas.draw_glyph(px, py, ch)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _draw_polyline(canvas: Canvas, points: list[tuple[int, int]], style: EdgeStyle) -> None:
|
|
804
|
+
for (x0, y0), (x1, y1) in zip(points, points[1:]):
|
|
805
|
+
canvas.draw_segment(x0, y0, x1, y1, style)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _draw_arrowheads(canvas: Canvas, points: list[tuple[int, int]], edge: FlowEdge) -> None:
|
|
809
|
+
if edge.dst_arrow:
|
|
810
|
+
canvas.draw_glyph(*points[-1], _arrow_glyph(points[-2], points[-1]))
|
|
811
|
+
if edge.src_arrow:
|
|
812
|
+
canvas.draw_glyph(*points[0], _arrow_glyph(points[1], points[0]))
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _self_loop_points(
|
|
816
|
+
direction: Direction, rect: BoxRect, lane: int
|
|
817
|
+
) -> list[tuple[int, int]]:
|
|
818
|
+
"""A small loop off the box's lane side — the flowchart analogue of the
|
|
819
|
+
sequence renderer's self-message loop (``_self_loop_rows``). Uses the
|
|
820
|
+
box's *full* border range (corners included) rather than only interior
|
|
821
|
+
rows/columns so it stays correct even at ``_MIN_BOX_W``/``_MIN_BOX_H``,
|
|
822
|
+
where there is only one interior row/column to work with."""
|
|
823
|
+
reach = _LOOP_REACH + lane * 2
|
|
824
|
+
ax, ay = _lane_anchor(direction, rect)
|
|
825
|
+
if _rank_is_horizontal(direction):
|
|
826
|
+
full_lo, full_hi = rect.x, rect.x + rect.w - 1
|
|
827
|
+
lo = max(full_lo, ax - 1)
|
|
828
|
+
hi = min(full_hi, ax + 1)
|
|
829
|
+
if hi <= lo:
|
|
830
|
+
hi = min(full_hi, lo + 1)
|
|
831
|
+
if hi <= lo:
|
|
832
|
+
lo = max(full_lo, hi - 1)
|
|
833
|
+
far = ay + reach
|
|
834
|
+
return [(lo, ay), (lo, far), (hi, far), (hi, ay)]
|
|
835
|
+
full_lo, full_hi = rect.y, rect.y + rect.h - 1
|
|
836
|
+
lo = max(full_lo, ay - 1)
|
|
837
|
+
hi = min(full_hi, ay + 1)
|
|
838
|
+
if hi <= lo:
|
|
839
|
+
hi = min(full_hi, lo + 1)
|
|
840
|
+
if hi <= lo:
|
|
841
|
+
lo = max(full_lo, hi - 1)
|
|
842
|
+
far = ax + reach
|
|
843
|
+
return [(ax, lo), (far, lo), (far, hi), (ax, hi)]
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _route_edge(
|
|
847
|
+
canvas: Canvas,
|
|
848
|
+
rects: dict[str, BoxRect],
|
|
849
|
+
direction: Direction,
|
|
850
|
+
edge: FlowEdge,
|
|
851
|
+
lane_counter: list[int],
|
|
852
|
+
self_loop_counter: dict[str, int],
|
|
853
|
+
) -> None:
|
|
854
|
+
"""Draw one edge's full orthogonal path: anchors chosen by relative
|
|
855
|
+
rank position, an L/Z/C path shape, arrowheads, and an edge label.
|
|
856
|
+
Dangling node-id references are a defensive no-op (the parser
|
|
857
|
+
guarantees valid endpoints; this module never crashes on one).
|
|
858
|
+
"""
|
|
548
859
|
src_rect = rects.get(edge.src)
|
|
549
860
|
dst_rect = rects.get(edge.dst)
|
|
550
861
|
if src_rect is None or dst_rect is None:
|
|
551
862
|
return
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
863
|
+
|
|
864
|
+
if edge.src == edge.dst:
|
|
865
|
+
lane = self_loop_counter.get(edge.src, 0)
|
|
866
|
+
self_loop_counter[edge.src] = lane + 1
|
|
867
|
+
points = _self_loop_points(direction, src_rect, lane)
|
|
556
868
|
else:
|
|
557
|
-
|
|
558
|
-
|
|
869
|
+
src_extent = _rank_extent(direction, src_rect)
|
|
870
|
+
dst_extent = _rank_extent(direction, dst_rect)
|
|
871
|
+
same_rank = not (dst_extent[1] < src_extent[0] or src_extent[1] < dst_extent[0])
|
|
872
|
+
|
|
873
|
+
if same_rank:
|
|
874
|
+
points = [_facing_anchor(src_rect, dst_rect), _facing_anchor(dst_rect, src_rect)]
|
|
875
|
+
else:
|
|
876
|
+
src_center = (src_extent[0] + src_extent[1]) / 2
|
|
877
|
+
dst_center = (dst_extent[0] + dst_extent[1]) / 2
|
|
878
|
+
forward = (dst_center > src_center) == _forward_increasing(direction)
|
|
879
|
+
if forward:
|
|
880
|
+
points = _z_path(
|
|
881
|
+
direction,
|
|
882
|
+
_forward_exit(direction, src_rect),
|
|
883
|
+
_forward_entry(direction, dst_rect),
|
|
884
|
+
)
|
|
885
|
+
else:
|
|
886
|
+
lane = lane_counter[0]
|
|
887
|
+
lane_counter[0] += 1
|
|
888
|
+
lane_secondary = (
|
|
889
|
+
_lane_secondary_base(direction, src_rect, dst_rect)
|
|
890
|
+
+ _LANE_MARGIN
|
|
891
|
+
+ lane * _LANE_GAP
|
|
892
|
+
)
|
|
893
|
+
points = _lane_path(
|
|
894
|
+
direction,
|
|
895
|
+
_lane_anchor(direction, src_rect),
|
|
896
|
+
_lane_anchor(direction, dst_rect),
|
|
897
|
+
lane_secondary,
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
_draw_polyline(canvas, points, edge.style)
|
|
901
|
+
_draw_arrowheads(canvas, points, edge)
|
|
902
|
+
|
|
903
|
+
if edge.label:
|
|
904
|
+
seg = _longest_segment(points)
|
|
905
|
+
if seg is not None:
|
|
906
|
+
_draw_label_on_segment(canvas, *seg, edge.label)
|
|
559
907
|
|
|
560
908
|
|
|
561
909
|
# --------------------------------------------------------------------------
|
|
@@ -595,8 +943,10 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
|
|
|
595
943
|
rect = rects.get(n.id)
|
|
596
944
|
if rect is not None:
|
|
597
945
|
canvas.draw_box(rect, n.shape, n.label)
|
|
946
|
+
lane_counter = [0]
|
|
947
|
+
self_loop_counter: dict[str, int] = {}
|
|
598
948
|
for e in g.edges:
|
|
599
|
-
|
|
949
|
+
_route_edge(canvas, rects, g.direction, e, lane_counter, self_loop_counter)
|
|
600
950
|
return canvas.to_lines()
|
|
601
951
|
except Exception:
|
|
602
952
|
return []
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""End-to-end tests for the public ``render_flowchart`` entry point.
|
|
2
|
+
|
|
3
|
+
These exercise real mermaid source strings through the full pipeline
|
|
4
|
+
(``parse -> layout_flowgraph -> lines``) and assert genuine rendered
|
|
5
|
+
geometry/topology — arrowhead glyphs, edge labels, row ordering, and the
|
|
6
|
+
degradation contract — not merely "no exception raised".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from termrender.renderers.mermaid_flow import render_flowchart
|
|
14
|
+
|
|
15
|
+
_BOX_GLYPH_RE = re.compile(r"[\u2500-\u259F\u25A0-\u25FF]")
|
|
16
|
+
_ARROW_RE = re.compile(r"[\u25b2\u25bc\u25b6\u25c0]") # ▲▼▶◀
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _row_of(lines: list[str], label: str) -> int:
|
|
20
|
+
for i, line in enumerate(lines):
|
|
21
|
+
if label in line:
|
|
22
|
+
return i
|
|
23
|
+
raise AssertionError(f"label {label!r} not found in rendered output: {lines!r}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --------------------------------------------------------------------------
|
|
27
|
+
# Forward DAG — arrowheads point down into children
|
|
28
|
+
# --------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_forward_dag_renders_with_arrowheads_into_children():
|
|
32
|
+
source = "graph TD\nA-->B\nA-->C\n"
|
|
33
|
+
lines = render_flowchart(source, width=80)
|
|
34
|
+
assert lines
|
|
35
|
+
text = "\n".join(lines)
|
|
36
|
+
for label in ("A", "B", "C"):
|
|
37
|
+
assert label in text
|
|
38
|
+
assert any(_BOX_GLYPH_RE.search(line) for line in lines)
|
|
39
|
+
# Real topology: A is above both its children.
|
|
40
|
+
row_a = _row_of(lines, "A")
|
|
41
|
+
assert row_a < _row_of(lines, "B")
|
|
42
|
+
assert row_a < _row_of(lines, "C")
|
|
43
|
+
# A down-pointing arrowhead lands on a child's top border.
|
|
44
|
+
assert "\u25bc" in text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------------------------
|
|
48
|
+
# Edge label — |label| form
|
|
49
|
+
# --------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_pipe_label_edge_shows_label_text():
|
|
53
|
+
source = "graph TD\nA-->|hello|B\n"
|
|
54
|
+
lines = render_flowchart(source, width=80)
|
|
55
|
+
assert lines
|
|
56
|
+
text = "\n".join(lines)
|
|
57
|
+
assert "A" in text and "B" in text
|
|
58
|
+
assert "hello" in text
|
|
59
|
+
assert any(_BOX_GLYPH_RE.search(line) for line in lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_inline_label_edge_shows_label_text():
|
|
63
|
+
source = "graph TD\nA -- go now --> B\n"
|
|
64
|
+
lines = render_flowchart(source, width=80)
|
|
65
|
+
assert lines
|
|
66
|
+
text = "\n".join(lines)
|
|
67
|
+
assert "go now" in text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------
|
|
71
|
+
# Labeled back-edge cycle — the exact shape that panics the Go binary
|
|
72
|
+
# --------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_labeled_back_edge_cycle_renders_all_boxes_label_and_arrow():
|
|
76
|
+
source = "graph TD\nA-->B\nB-->C\nC-->|retry|A\n"
|
|
77
|
+
lines = render_flowchart(source, width=80)
|
|
78
|
+
assert lines, "must render, never raise or degrade to echo"
|
|
79
|
+
text = "\n".join(lines)
|
|
80
|
+
for label in ("A", "B", "C"):
|
|
81
|
+
assert label in text
|
|
82
|
+
assert "retry" in text
|
|
83
|
+
assert any(_BOX_GLYPH_RE.search(line) for line in lines)
|
|
84
|
+
# An arrowhead lands somewhere (the back-edge's ◀/▲ into A, or the
|
|
85
|
+
# forward edges' ▼ into B/C) — at minimum the diagram is not headless.
|
|
86
|
+
assert _ARROW_RE.search(text)
|
|
87
|
+
# Real topology check: the forward chain A -> B -> C still orders
|
|
88
|
+
# top-to-bottom despite the labeled back-edge C -> A.
|
|
89
|
+
row_a, row_b, row_c = _row_of(lines, "A"), _row_of(lines, "B"), _row_of(lines, "C")
|
|
90
|
+
assert row_a < row_b < row_c
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------
|
|
94
|
+
# Multi-parent DAG — both edges attach to the shared child
|
|
95
|
+
# --------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_multi_parent_dag_attaches_both_edges_to_shared_child():
|
|
99
|
+
source = "graph TD\nA-->C\nB-->C\n"
|
|
100
|
+
lines = render_flowchart(source, width=80)
|
|
101
|
+
assert lines
|
|
102
|
+
text = "\n".join(lines)
|
|
103
|
+
for label in ("A", "B", "C"):
|
|
104
|
+
assert label in text
|
|
105
|
+
row_a, row_b, row_c = _row_of(lines, "A"), _row_of(lines, "B"), _row_of(lines, "C")
|
|
106
|
+
assert row_a < row_c
|
|
107
|
+
assert row_b < row_c
|
|
108
|
+
|
|
109
|
+
# Real geometry: A and B sit side by side in the same rank (row), and
|
|
110
|
+
# each routes its own vertical leg down toward C — the inter-rank gap
|
|
111
|
+
# rows must show line glyphs at (at least) two distinct columns,
|
|
112
|
+
# evidencing two separate incoming connections rather than one shared
|
|
113
|
+
# trunk that happens to touch both labels.
|
|
114
|
+
assert row_a == row_b
|
|
115
|
+
gap_lo, gap_hi = min(row_a, row_b) + 1, row_c
|
|
116
|
+
vertical_cols: set[int] = set()
|
|
117
|
+
for row in range(gap_lo, gap_hi):
|
|
118
|
+
if row < 0 or row >= len(lines):
|
|
119
|
+
continue
|
|
120
|
+
for col, ch in enumerate(lines[row]):
|
|
121
|
+
if ch in "\u2502\u2514\u2518\u250c\u2510\u251c\u2524\u252c\u2534\u253c":
|
|
122
|
+
vertical_cols.add(col)
|
|
123
|
+
assert len(vertical_cols) >= 2, (
|
|
124
|
+
f"expected >=2 distinct incoming columns into C, got {vertical_cols}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------
|
|
129
|
+
# Headless edge — `---` draws a line with no arrowhead
|
|
130
|
+
# --------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_headless_edge_has_no_arrowhead():
|
|
134
|
+
source = "graph TD\nA---B\n"
|
|
135
|
+
lines = render_flowchart(source, width=80)
|
|
136
|
+
assert lines
|
|
137
|
+
text = "\n".join(lines)
|
|
138
|
+
assert "A" in text and "B" in text
|
|
139
|
+
assert any(_BOX_GLYPH_RE.search(line) for line in lines)
|
|
140
|
+
assert not _ARROW_RE.search(text), "`---` must draw a plain line, no arrowhead"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------
|
|
144
|
+
# Degradation contract
|
|
145
|
+
# --------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_malformed_input_raw_echoes_with_no_box_glyphs():
|
|
149
|
+
source = "not a diagram\njust some text\n"
|
|
150
|
+
lines = render_flowchart(source, width=80)
|
|
151
|
+
assert lines == ["not a diagram", "just some text"]
|
|
152
|
+
text = "\n".join(lines)
|
|
153
|
+
assert not _BOX_GLYPH_RE.search(text), "raw echo must contain no box-drawing glyphs"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_empty_body_headed_diagram_raw_echoes():
|
|
157
|
+
source = "graph TD\n%% just a comment, no nodes\n"
|
|
158
|
+
lines = render_flowchart(source, width=80)
|
|
159
|
+
assert lines == ["graph TD", "%% just a comment, no nodes"]
|
|
160
|
+
text = "\n".join(lines)
|
|
161
|
+
assert not _BOX_GLYPH_RE.search(text)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_render_flowchart_never_raises_on_garbage_input():
|
|
165
|
+
# Defensive: assorted odd inputs must never raise, always return a list.
|
|
166
|
+
for source in ("", "\n\n\n", "graph", "flowchart LR\n", "graph TD\nA-->\n"):
|
|
167
|
+
lines = render_flowchart(source, width=80)
|
|
168
|
+
assert isinstance(lines, list)
|
|
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
|