termrender 4.1.1__tar.gz → 4.2.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.
Files changed (59) hide show
  1. {termrender-4.1.1 → termrender-4.2.0}/CHANGELOG.md +49 -0
  2. {termrender-4.1.1 → termrender-4.2.0}/PKG-INFO +1 -1
  3. termrender-4.2.0/src/termrender/renderers/mermaid_sequence.py +597 -0
  4. termrender-4.2.0/tests/test_mermaid_sequence.py +463 -0
  5. {termrender-4.1.1 → termrender-4.2.0}/.github/workflows/publish.yml +0 -0
  6. {termrender-4.1.1 → termrender-4.2.0}/.gitignore +0 -0
  7. {termrender-4.1.1 → termrender-4.2.0}/CLAUDE.md +0 -0
  8. {termrender-4.1.1 → termrender-4.2.0}/LICENSE +0 -0
  9. {termrender-4.1.1 → termrender-4.2.0}/README.md +0 -0
  10. {termrender-4.1.1 → termrender-4.2.0}/design.json +0 -0
  11. {termrender-4.1.1 → termrender-4.2.0}/pyproject.toml +0 -0
  12. {termrender-4.1.1 → termrender-4.2.0}/requirements.json +0 -0
  13. {termrender-4.1.1 → termrender-4.2.0}/scripts/build-mermaid-ascii.sh +0 -0
  14. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/CLAUDE.md +0 -0
  15. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/__init__.py +0 -0
  16. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/__main__.py +0 -0
  17. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  18. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/_mermaid_bin.py +0 -0
  19. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/blocks.py +0 -0
  20. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/emit.py +0 -0
  21. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/layout.py +0 -0
  22. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/parser.py +0 -0
  23. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/py.typed +0 -0
  24. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/CLAUDE.md +0 -0
  25. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/__init__.py +0 -0
  26. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/borders.py +0 -0
  27. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/charts.py +0 -0
  28. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/code.py +0 -0
  29. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/columns.py +0 -0
  30. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/diff.py +0 -0
  31. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/divider.py +0 -0
  32. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/mermaid.py +0 -0
  33. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
  34. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/mermaid_pie.py +0 -0
  35. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/panel.py +0 -0
  36. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/quote.py +0 -0
  37. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/stat.py +0 -0
  38. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/table.py +0 -0
  39. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/text.py +0 -0
  40. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/timeline.py +0 -0
  41. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/renderers/tree.py +0 -0
  42. {termrender-4.1.1 → termrender-4.2.0}/src/termrender/style.py +0 -0
  43. {termrender-4.1.1 → termrender-4.2.0}/tests/__init__.py +0 -0
  44. {termrender-4.1.1 → termrender-4.2.0}/tests/test_charts.py +0 -0
  45. {termrender-4.1.1 → termrender-4.2.0}/tests/test_cli_contract.py +0 -0
  46. {termrender-4.1.1 → termrender-4.2.0}/tests/test_column_alignment.py +0 -0
  47. {termrender-4.1.1 → termrender-4.2.0}/tests/test_diff.py +0 -0
  48. {termrender-4.1.1 → termrender-4.2.0}/tests/test_inline_badge.py +0 -0
  49. {termrender-4.1.1 → termrender-4.2.0}/tests/test_linebreak.py +0 -0
  50. {termrender-4.1.1 → termrender-4.2.0}/tests/test_mermaid_compat.py +0 -0
  51. {termrender-4.1.1 → termrender-4.2.0}/tests/test_mermaid_dispatch.py +0 -0
  52. {termrender-4.1.1 → termrender-4.2.0}/tests/test_mermaid_gantt.py +0 -0
  53. {termrender-4.1.1 → termrender-4.2.0}/tests/test_mermaid_pie.py +0 -0
  54. {termrender-4.1.1 → termrender-4.2.0}/tests/test_myst_gaps.py +0 -0
  55. {termrender-4.1.1 → termrender-4.2.0}/tests/test_stat.py +0 -0
  56. {termrender-4.1.1 → termrender-4.2.0}/tests/test_tasklist.py +0 -0
  57. {termrender-4.1.1 → termrender-4.2.0}/tests/test_timeline.py +0 -0
  58. {termrender-4.1.1 → termrender-4.2.0}/tests/test_variable_colons.py +0 -0
  59. {termrender-4.1.1 → termrender-4.2.0}/uv.lock +0 -0
@@ -1,6 +1,55 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.2.0 (2026-07-06)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **mermaid-sequence**: Reject dash-adjacent punctuation as part of an id
9
+ ([`612f36d`](https://github.com/crouton-labs/termrender/commit/612f36d14d68e6c51f4ac6d16f0ae837e44ddd01))
10
+
11
+ Reviewer found: near-miss arrow syntax with no sequence-diagram equivalent (e.g. flowchart-only
12
+ `A-.->B`, or garbage like `A==>>B`) was silently absorbed into the source id ("A-.") instead of
13
+ degrading to a plain line, because the arrow regex's identifier token was \S+/\S+? (anything
14
+ non-space) and would backtrack through dash/dot punctuation until some later '>' happened to
15
+ complete a valid-looking arrow.
16
+
17
+ Fix: identifiers now exclude '-' (plus ':', '+', whitespace) — dash is reserved for the arrow
18
+ itself, so an id is the maximal run up to the first dash, eliminating the backtracking ambiguity
19
+ entirely. Also documents the pre-existing (accepted, mirrors wrap_text()'s CJK bug elsewhere in
20
+ termrender) character-index vs visual-width limitation in the module docstring, per independent
21
+ review (non-blocking, not fixed here — same class of fix as the documented wrap_text() constraint,
22
+ out of proportion for a renderer not yet wired into the dispatcher).
23
+
24
+ Adds 2 regression tests for the repro cases. 191 tests pass (full suite, up from 189).
25
+
26
+ ### Features
27
+
28
+ - Add native ASCII sequence-diagram renderer for mermaid
29
+ ([`ad63977`](https://github.com/crouton-labs/termrender/commit/ad639774edcbcd744ba7f2183c4f5d8c83630af0))
30
+
31
+ Standalone module (src/termrender/renderers/mermaid_sequence.py) exposing render_sequence(source,
32
+ width) -> list[str]. Not wired into the mermaid.py dispatcher yet (a later integration phase owns
33
+ that).
34
+
35
+ Layout: participant boxes across the top/bottom, vertical lifelines, messages as horizontal arrows
36
+ in declaration order, self-messages as a loop-back. Column spacing is a 1D constraint pass driven
37
+ by participant label widths and the messages crossing each gap (no 2D layout search needed since
38
+ message order already fixes the vertical axis).
39
+
40
+ Grammar: participant/actor with 'as' aliases plus implicit participants from first use; all 8 arrow
41
+ forms (->, -->, ->>, -->>, -x, --x, -), --)) with distinct line style (solid/dashed) and arrowhead
42
+ glyph per marker; Note over/left of/right of; autonumber (start/step); loop/alt/opt/par/
43
+ critical/break/rect/box render as labeled separator bands (else/and/ option as mid-band
44
+ separators); activate/deactivate (including the +/- arrow shorthand) and <br/> are tolerated and
45
+ flattened; any other unrecognized or malformed line (stray else/end, unknown directives) degrades
46
+ to a plain text line rather than raising.
47
+
48
+ Tests: tests/test_mermaid_sequence.py, 40 cases covering golden output, all arrow variants, aliases,
49
+ implicit participant ordering, notes, autonumber, block nesting, degradation paths, and the
50
+ no-trailing-whitespace contract. Full suite (189 tests) still green.
51
+
52
+
4
53
  ## v4.1.1 (2026-07-06)
5
54
 
6
55
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.1.1
3
+ Version: 4.2.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,597 @@
1
+ """Native ASCII renderer for mermaid ``sequenceDiagram`` sources.
2
+
3
+ Standalone module: exposes a single pure function, ``render_sequence``.
4
+ Not wired into the ``mermaid.py`` dispatcher yet (a later integration
5
+ phase owns that); this module has no dependency on ``Block`` or the
6
+ rest of the renderer pipeline, and nothing here is imported elsewhere.
7
+
8
+ Layout model
9
+ ------------
10
+ Participants become columns (a lifeline ``│`` per column, boxed labels
11
+ at top and bottom). Messages render in declaration order, top to bottom,
12
+ as horizontal arrows between two columns; a message from/to the same
13
+ column becomes a small loop drawn to the right of its lifeline. Column
14
+ spacing is computed once, bottom-up from participant label widths and
15
+ the messages that cross each gap (a message spanning two adjacent
16
+ columns needs a wide-enough gap; a message spanning several columns
17
+ distributes the requirement across the gaps it crosses) — this is a 1D
18
+ constraint pass, not a 2D layout search: message order already fixes
19
+ the vertical axis, and declaration order fixes the column axis.
20
+
21
+ Grammar supported
22
+ ------------------
23
+ ``participant``/``actor`` (with ``as`` aliases) and implicit participants
24
+ introduced by first use in a message or note; all eight core arrow forms
25
+ (``->``, ``-->``, ``->>``, ``-->>``, ``-x``, ``--x``, ``-)``, ``--)``);
26
+ ``Note over/left of/right of``; ``autonumber`` (with optional start/step);
27
+ ``activate``/``deactivate`` (including the ``+``/``-`` arrow-decoration
28
+ shorthand) and ``<br/>`` are tolerated and flattened (no visual effect).
29
+ ``loop``/``alt``/``opt``/``par``/``critical``/``break``/``rect``/``box``
30
+ open a horizontal separator band (with ``else``/``and``/``option`` as a
31
+ mid-band separator inside one); a stray separator/``end`` with no open
32
+ block, or any other unrecognized line, degrades to a plain text line
33
+ rather than raising.
34
+
35
+ Known degradations (by design, not bugs)
36
+ -----------------------------------------
37
+ - A ``Note left of`` the first participant, or block/plain labels wider
38
+ than the diagram, may overflow the canvas to the left/right; overflow
39
+ is acceptable (matching the existing flowchart renderer's contract),
40
+ never corruption or a crash.
41
+ - ``Note over``/``left of``/``right of`` with more than one participant
42
+ listed uses the first-and-last (or first, for left/right of) named
43
+ participant only — mirrors the simplification already used by the
44
+ Go-backed renderer's ``Note`` preprocessing.
45
+ - Participants are never created/destroyed mid-diagram; every lifeline
46
+ spans the full height of the diagram.
47
+ - Column/row placement is character-index-based, not visual-width-based
48
+ (``_Row`` indexes by ``len()``, not ``visual_len()``); wide (CJK/full-width)
49
+ participant labels or message text will misalign columns. This mirrors
50
+ the pre-existing ``wrap_text()`` CJK limitation elsewhere in termrender
51
+ (see the root CLAUDE.md); fixing it here requires the same visual-width
52
+ rework, out of scope for this renderer alone.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import re
58
+ from dataclasses import dataclass
59
+
60
+ from termrender.style import visual_center, visual_len
61
+
62
+ __all__ = ["render_sequence", "SequenceDiagramError"]
63
+
64
+
65
+ class SequenceDiagramError(Exception):
66
+ """Raised when source cannot be parsed as a mermaid sequence diagram."""
67
+
68
+
69
+ # --------------------------------------------------------------------------
70
+ # Parsed event model
71
+ # --------------------------------------------------------------------------
72
+
73
+
74
+ @dataclass
75
+ class Participant:
76
+ id: str
77
+ label: str
78
+
79
+
80
+ @dataclass
81
+ class Arrow:
82
+ source: str
83
+ target: str
84
+ dashed: bool
85
+ head_kind: str # "filled" | "open" | "lost" | "async"
86
+ text: str
87
+ number: int | None = None
88
+
89
+
90
+ @dataclass
91
+ class NoteEvent:
92
+ participants: list[str]
93
+ position: str # "over" | "left" | "right"
94
+ text: str
95
+
96
+
97
+ @dataclass
98
+ class BlockBoundary:
99
+ kind: str # "start" | "sep" | "end"
100
+ label: str
101
+
102
+
103
+ @dataclass
104
+ class PlainLabel:
105
+ text: str
106
+
107
+
108
+ Event = Arrow | NoteEvent | BlockBoundary | PlainLabel
109
+
110
+
111
+ # --------------------------------------------------------------------------
112
+ # Grammar
113
+ # --------------------------------------------------------------------------
114
+
115
+ _COMMENT_RE = re.compile(r"^%%")
116
+ _BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
117
+ _AUTONUMBER_RE = re.compile(r"^autonumber(?:\s+(\d+))?(?:\s+(\d+))?\s*$", re.IGNORECASE)
118
+ _ACTIVATE_RE = re.compile(r"^(?:activate|deactivate)\s+\S+\s*$", re.IGNORECASE)
119
+ _PARTICIPANT_RE = re.compile(
120
+ r"^(?:participant|actor)\s+(\S+)(?:\s+as\s+(.+))?\s*$", re.IGNORECASE
121
+ )
122
+ _NOTE_RE = re.compile(
123
+ r"^[Nn]ote\s+(over|left\s+of|right\s+of)\s+([^:]+?)\s*:\s*(.*)$"
124
+ )
125
+ _BLOCK_START_RE = re.compile(
126
+ r"^(loop|alt|opt|par|critical|break|rect|box)\b\s*(.*)$", re.IGNORECASE
127
+ )
128
+ _BLOCK_SEP_RE = re.compile(r"^(else|and|option)\b\s*(.*)$", re.IGNORECASE)
129
+ _BLOCK_END_RE = re.compile(r"^end\s*$", re.IGNORECASE)
130
+ # Arrow forms: "-{1,2}" dash count marks solid vs dashed; the marker after it
131
+ # picks the arrowhead. Handles all 8 combinations: ->, -->, ->>, -->>, -x,
132
+ # --x, -), --). An optional +/- right after the arrow (activate/deactivate
133
+ # shorthand) is captured and discarded.
134
+ #
135
+ # Identifiers deliberately exclude '-' (and ':', '+', whitespace): dash is
136
+ # reserved for the arrow itself, so an id token is the maximal run up to the
137
+ # first dash. Without this exclusion, non-mermaid dash punctuation next to a
138
+ # real arrow char (e.g. "A-.->B", a flowchart-only dotted arrow with no
139
+ # sequence-diagram equivalent) gets silently absorbed into the id instead of
140
+ # failing to match and degrading to a plain line.
141
+ _ID = r"[^\s:+-]+"
142
+ _ARROW_RE = re.compile(
143
+ rf"^({_ID})\s*(-{{1,2}})(>>|>|x|\))\s*([+-]?)({_ID})\s*:\s*(.*)$"
144
+ )
145
+
146
+ _HEAD_KIND = {">>": "filled", ">": "open", "x": "lost", ")": "async"}
147
+ _HEAD_GLYPH = {
148
+ "filled": {"right": ">", "left": "<"},
149
+ "open": {"right": "\u203a", "left": "\u2039"}, # › ‹
150
+ "lost": {"right": "\u2717", "left": "\u2717"}, # ✗
151
+ "async": {"right": ")", "left": "("},
152
+ }
153
+ _LINE_CHAR = {False: "\u2500", True: "\u254c"} # solid ─ / dashed ╌
154
+
155
+ _MIN_ARROW_GAP = 6
156
+ _MIN_SELF_GAP = 6
157
+ _BOX_PAD = 3
158
+
159
+
160
+ def _flatten(text: str) -> str:
161
+ return _BR_RE.sub(" / ", text) if text else ""
162
+
163
+
164
+ def _format_label(ev: Arrow) -> str:
165
+ text = _flatten(ev.text)
166
+ if ev.number is not None:
167
+ return f"{ev.number}: {text}" if text else f"{ev.number}:"
168
+ return text
169
+
170
+
171
+ def _note_position(raw: str) -> str:
172
+ low = raw.lower()
173
+ if low == "over":
174
+ return "over"
175
+ return "left" if "left" in low else "right"
176
+
177
+
178
+ def _parse(lines: list[str]) -> tuple[list[Participant], list[Event]]:
179
+ participants: dict[str, Participant] = {}
180
+ events: list[Event] = []
181
+ stack: list[str] = []
182
+ seen_header = False
183
+ autonumber_active = False
184
+ autonumber_next = 1
185
+ autonumber_step = 1
186
+
187
+ def register(pid: str) -> None:
188
+ if pid not in participants:
189
+ participants[pid] = Participant(id=pid, label=pid)
190
+
191
+ for raw_line in lines:
192
+ line = raw_line.strip()
193
+ if not line:
194
+ continue
195
+ if not seen_header:
196
+ if line.lower().startswith("sequencediagram"):
197
+ seen_header = True
198
+ continue
199
+ if _COMMENT_RE.match(line):
200
+ continue
201
+
202
+ m = _AUTONUMBER_RE.match(line)
203
+ if m:
204
+ autonumber_active = True
205
+ if m.group(1):
206
+ autonumber_next = int(m.group(1))
207
+ if m.group(2):
208
+ autonumber_step = int(m.group(2))
209
+ continue
210
+
211
+ if _ACTIVATE_RE.match(line):
212
+ continue
213
+
214
+ m = _PARTICIPANT_RE.match(line)
215
+ if m:
216
+ pid, alias = m.group(1), m.group(2)
217
+ label = alias.strip() if alias else pid
218
+ if pid in participants:
219
+ if alias:
220
+ participants[pid].label = label
221
+ else:
222
+ participants[pid] = Participant(id=pid, label=label)
223
+ continue
224
+
225
+ m = _NOTE_RE.match(line)
226
+ if m:
227
+ position_raw, who, text = m.group(1), m.group(2), m.group(3)
228
+ who_ids = [w.strip() for w in who.split(",") if w.strip()]
229
+ for w in who_ids:
230
+ register(w)
231
+ events.append(
232
+ NoteEvent(
233
+ participants=who_ids,
234
+ position=_note_position(position_raw),
235
+ text=_flatten(text),
236
+ )
237
+ )
238
+ continue
239
+
240
+ m = _BLOCK_START_RE.match(line)
241
+ if m:
242
+ keyword, rest = m.group(1), m.group(2).strip()
243
+ label = f"{keyword} {rest}".strip() if rest else keyword
244
+ stack.append(label)
245
+ events.append(BlockBoundary(kind="start", label=label))
246
+ continue
247
+
248
+ m = _BLOCK_SEP_RE.match(line)
249
+ if m:
250
+ keyword, rest = m.group(1), m.group(2).strip()
251
+ label = f"{keyword} {rest}".strip() if rest else keyword
252
+ if stack:
253
+ events.append(BlockBoundary(kind="sep", label=label))
254
+ else:
255
+ events.append(PlainLabel(text=_flatten(line)))
256
+ continue
257
+
258
+ if _BLOCK_END_RE.match(line):
259
+ if stack:
260
+ stack.pop()
261
+ events.append(BlockBoundary(kind="end", label=""))
262
+ continue
263
+
264
+ m = _ARROW_RE.match(line)
265
+ if m:
266
+ source, dashes, marker, _decor, target, text = m.groups()
267
+ register(source)
268
+ register(target)
269
+ number = None
270
+ if autonumber_active:
271
+ number = autonumber_next
272
+ autonumber_next += autonumber_step
273
+ events.append(
274
+ Arrow(
275
+ source=source,
276
+ target=target,
277
+ dashed=len(dashes) == 2,
278
+ head_kind=_HEAD_KIND[marker],
279
+ text=text.strip(),
280
+ number=number,
281
+ )
282
+ )
283
+ continue
284
+
285
+ events.append(PlainLabel(text=_flatten(line)))
286
+
287
+ return list(participants.values()), events
288
+
289
+
290
+ # --------------------------------------------------------------------------
291
+ # Column layout
292
+ # --------------------------------------------------------------------------
293
+
294
+
295
+ def _layout_columns(
296
+ participants: list[Participant], events: list[Event]
297
+ ) -> tuple[list[int], list[int]]:
298
+ n = len(participants)
299
+ if n == 0:
300
+ return [], []
301
+
302
+ id_index = {p.id: i for i, p in enumerate(participants)}
303
+ box_widths = [visual_len(p.label) + 4 for p in participants]
304
+ gaps = [
305
+ box_widths[i] // 2 + box_widths[i + 1] // 2 + _BOX_PAD for i in range(n - 1)
306
+ ]
307
+
308
+ for ev in events:
309
+ if not isinstance(ev, Arrow):
310
+ continue
311
+ if ev.source not in id_index or ev.target not in id_index:
312
+ continue
313
+ si, ti = id_index[ev.source], id_index[ev.target]
314
+ label_text = _format_label(ev)
315
+ if si == ti:
316
+ if si < n - 1:
317
+ required = max(visual_len(label_text) + 4, _MIN_SELF_GAP)
318
+ gaps[si] = max(gaps[si], required)
319
+ continue
320
+
321
+ lo, hi = (si, ti) if si < ti else (ti, si)
322
+ required = max(visual_len(label_text) + 2, _MIN_ARROW_GAP)
323
+ if hi == lo + 1:
324
+ gaps[lo] = max(gaps[lo], required)
325
+ else:
326
+ span = range(lo, hi)
327
+ count = hi - lo
328
+ current = sum(gaps[i] for i in span)
329
+ if current < required:
330
+ deficit = required - current
331
+ share, rem = divmod(deficit, count)
332
+ for j, i in enumerate(span):
333
+ gaps[i] += share + (1 if j < rem else 0)
334
+
335
+ centers = [0] * n
336
+ centers[0] = box_widths[0] // 2
337
+ for i in range(1, n):
338
+ centers[i] = centers[i - 1] + gaps[i - 1]
339
+ return centers, box_widths
340
+
341
+
342
+ # --------------------------------------------------------------------------
343
+ # Row rendering
344
+ # --------------------------------------------------------------------------
345
+
346
+
347
+ class _Row:
348
+ """A growable line of characters, addressed by absolute column."""
349
+
350
+ __slots__ = ("chars",)
351
+
352
+ def __init__(self, width: int) -> None:
353
+ self.chars: list[str] = [" "] * max(width, 0)
354
+
355
+ def set(self, x: int, s: str) -> None:
356
+ if x < 0 or not s:
357
+ return
358
+ end = x + len(s)
359
+ if end > len(self.chars):
360
+ self.chars.extend([" "] * (end - len(self.chars)))
361
+ for i, ch in enumerate(s):
362
+ self.chars[x + i] = ch
363
+
364
+ def to_string(self) -> str:
365
+ return "".join(self.chars).rstrip()
366
+
367
+
368
+ def _lifeline_row(diagram_width: int, centers: list[int]) -> _Row:
369
+ row = _Row(diagram_width)
370
+ for x in centers:
371
+ row.set(x, "\u2502") # │
372
+ return row
373
+
374
+
375
+ def _participant_box(width: int, label: str) -> tuple[str, str, str]:
376
+ inner = max(width - 2, 0)
377
+ top = "\u250c" + "\u2500" * inner + "\u2510" # ┌───┐
378
+ mid = "\u2502" + visual_center(label, inner) + "\u2502"
379
+ bot = "\u2514" + "\u2500" * inner + "\u2518" # └───┘
380
+ return top, mid, bot
381
+
382
+
383
+ def _header_rows(
384
+ participants: list[Participant],
385
+ box_lefts: list[int],
386
+ box_widths: list[int],
387
+ diagram_width: int,
388
+ ) -> tuple[str, str, str]:
389
+ top_row = _Row(diagram_width)
390
+ mid_row = _Row(diagram_width)
391
+ bot_row = _Row(diagram_width)
392
+ for p, left, w in zip(participants, box_lefts, box_widths):
393
+ top, mid, bot = _participant_box(w, p.label)
394
+ top_row.set(left, top)
395
+ mid_row.set(left, mid)
396
+ bot_row.set(left, bot)
397
+ return top_row.to_string(), mid_row.to_string(), bot_row.to_string()
398
+
399
+
400
+ def _line_row(
401
+ diagram_width: int,
402
+ centers: list[int],
403
+ lo: int,
404
+ hi: int,
405
+ rightwards: bool,
406
+ dashed: bool,
407
+ head_kind: str,
408
+ ) -> str:
409
+ row = _lifeline_row(diagram_width, centers)
410
+ line_char = _LINE_CHAR[dashed]
411
+ x0, x1 = centers[lo], centers[hi]
412
+ for x in range(x0, x1 + 1):
413
+ row.set(x, line_char)
414
+ glyphs = _HEAD_GLYPH[head_kind]
415
+ if rightwards:
416
+ row.set(x1, glyphs["right"])
417
+ else:
418
+ row.set(x0, glyphs["left"])
419
+ return row.to_string()
420
+
421
+
422
+ def _label_row(
423
+ diagram_width: int, centers: list[int], lo: int, hi: int, label_text: str
424
+ ) -> str | None:
425
+ if not label_text:
426
+ return None
427
+ row = _lifeline_row(diagram_width, centers)
428
+ span_width = centers[hi] - centers[lo] + 1
429
+ row.set(centers[lo], visual_center(label_text, span_width))
430
+ return row.to_string()
431
+
432
+
433
+ def _self_loop_rows(
434
+ diagram_width: int,
435
+ centers: list[int],
436
+ idx_s: int,
437
+ label_text: str,
438
+ dashed: bool,
439
+ head_kind: str,
440
+ ) -> list[str]:
441
+ x = centers[idx_s]
442
+ line_char = _LINE_CHAR[dashed]
443
+ glyphs = _HEAD_GLYPH[head_kind]
444
+
445
+ out_row = _lifeline_row(diagram_width, centers)
446
+ out_row.set(x, line_char + "\u256e") # ╮ (round down-right corner)
447
+
448
+ mid_row = _lifeline_row(diagram_width, centers)
449
+ label_part = ("\u2502 " + label_text) if label_text else "\u2502"
450
+ mid_row.set(x + 1, label_part)
451
+
452
+ in_row = _lifeline_row(diagram_width, centers)
453
+ in_row.set(x, glyphs["left"] + "\u256f") # ╯ (round up-left corner)
454
+
455
+ return [out_row.to_string(), mid_row.to_string(), in_row.to_string()]
456
+
457
+
458
+ def _note_box_rows(
459
+ diagram_width: int, centers: list[int], box_left: int, box_width: int, text: str
460
+ ) -> list[str]:
461
+ top, mid, bot = _participant_box(box_width, text)
462
+ rows = []
463
+ for content in (top, mid, bot):
464
+ row = _lifeline_row(diagram_width, centers)
465
+ row.set(max(box_left, 0), content)
466
+ rows.append(row.to_string())
467
+ return rows
468
+
469
+
470
+ def _band_row(diagram_width: int, kind: str, label: str) -> str:
471
+ if kind == "end":
472
+ fill = max(diagram_width - 2, 0)
473
+ return "\u2514" + "\u2500" * fill + "\u2518" # └───┘
474
+ left, right = ("\u250c", "\u2510") if kind == "start" else ("\u251c", "\u2524")
475
+ prefix = f"\u2500 {label} " if label else "\u2500 "
476
+ fill = max(diagram_width - 2 - visual_len(prefix), 0)
477
+ return left + prefix + "\u2500" * fill + right
478
+
479
+
480
+ def _plain_label_row(diagram_width: int, text: str) -> str:
481
+ row = _Row(diagram_width)
482
+ content = visual_center(text, diagram_width) if diagram_width > 0 else text
483
+ row.set(0, content)
484
+ return row.to_string()
485
+
486
+
487
+ def _render_arrow(
488
+ ev: Arrow, centers: list[int], id_index: dict[str, int], diagram_width: int
489
+ ) -> list[str]:
490
+ si, ti = id_index[ev.source], id_index[ev.target]
491
+ label_text = _format_label(ev)
492
+
493
+ if si == ti:
494
+ return _self_loop_rows(diagram_width, centers, si, label_text, ev.dashed, ev.head_kind)
495
+
496
+ lo, hi = (si, ti) if si < ti else (ti, si)
497
+ rightwards = si < ti
498
+ rows: list[str] = []
499
+ label_row = _label_row(diagram_width, centers, lo, hi, label_text)
500
+ if label_row is not None:
501
+ rows.append(label_row)
502
+ rows.append(_line_row(diagram_width, centers, lo, hi, rightwards, ev.dashed, ev.head_kind))
503
+ return rows
504
+
505
+
506
+ def _render_note(
507
+ ev: NoteEvent, centers: list[int], id_index: dict[str, int], diagram_width: int
508
+ ) -> list[str]:
509
+ idxs = [id_index[p] for p in ev.participants if p in id_index]
510
+ if not idxs:
511
+ return [_plain_label_row(diagram_width, f"Note: {ev.text}")]
512
+ lo, hi = min(idxs), max(idxs)
513
+ text = ev.text
514
+ box_width = visual_len(text) + 4
515
+
516
+ if ev.position == "over":
517
+ span_center = (centers[lo] + centers[hi]) // 2
518
+ box_width = max(box_width, centers[hi] - centers[lo] + 4)
519
+ box_left = span_center - box_width // 2
520
+ elif ev.position == "left":
521
+ box_left = centers[lo] - 2 - box_width
522
+ else: # "right"
523
+ box_left = centers[lo] + 2
524
+
525
+ return _note_box_rows(diagram_width, centers, box_left, box_width, text)
526
+
527
+
528
+ def _render_event(
529
+ ev: Event, centers: list[int], id_index: dict[str, int], diagram_width: int
530
+ ) -> list[str]:
531
+ if isinstance(ev, Arrow):
532
+ return _render_arrow(ev, centers, id_index, diagram_width)
533
+ if isinstance(ev, NoteEvent):
534
+ return _render_note(ev, centers, id_index, diagram_width)
535
+ if isinstance(ev, BlockBoundary):
536
+ return [_band_row(diagram_width, ev.kind, ev.label)]
537
+ if isinstance(ev, PlainLabel):
538
+ return [_plain_label_row(diagram_width, ev.text)]
539
+ raise SequenceDiagramError(f"unrenderable event: {ev!r}")
540
+
541
+
542
+ # --------------------------------------------------------------------------
543
+ # Public entry point
544
+ # --------------------------------------------------------------------------
545
+
546
+
547
+ def render_sequence(source: str, width: int) -> list[str]:
548
+ """Render a mermaid ``sequenceDiagram`` source to ASCII lines.
549
+
550
+ Args:
551
+ source: The mermaid fence body; its first non-blank line must
552
+ start with ``sequenceDiagram``.
553
+ width: Advisory terminal width. Sequence diagrams have an
554
+ intrinsic width driven by participant labels and message
555
+ text; this function does not wrap or truncate to fit —
556
+ overflow beyond ``width`` is acceptable, matching the
557
+ existing flowchart renderer's contract.
558
+
559
+ Returns:
560
+ Rendered lines: monochrome unicode box-drawing, no ANSI, no
561
+ significant trailing whitespace.
562
+
563
+ Raises:
564
+ SequenceDiagramError: If ``source`` is not a mermaid sequence
565
+ diagram at all (missing the ``sequenceDiagram`` header).
566
+ """
567
+ del width # advisory only; diagrams have intrinsic width (see docstring)
568
+
569
+ lines = source.splitlines()
570
+ first = next((line.strip() for line in lines if line.strip()), "")
571
+ if not first.lower().startswith("sequencediagram"):
572
+ raise SequenceDiagramError(
573
+ "not a mermaid sequence diagram: source must start with 'sequenceDiagram'"
574
+ )
575
+
576
+ participants, events = _parse(lines)
577
+ centers, box_widths = _layout_columns(participants, events)
578
+ box_lefts = [c - w // 2 for c, w in zip(centers, box_widths)]
579
+ diagram_width = (box_lefts[-1] + box_widths[-1]) if participants else 0
580
+
581
+ out: list[str] = []
582
+ header: tuple[str, str, str] | None = None
583
+ if participants:
584
+ header = _header_rows(participants, box_lefts, box_widths, diagram_width)
585
+ out.extend(header)
586
+ out.append(_lifeline_row(diagram_width, centers).to_string())
587
+
588
+ id_index = {p.id: i for i, p in enumerate(participants)}
589
+ for ev in events:
590
+ out.extend(_render_event(ev, centers, id_index, diagram_width))
591
+ if participants:
592
+ out.append(_lifeline_row(diagram_width, centers).to_string())
593
+
594
+ if header is not None:
595
+ out.extend(header)
596
+
597
+ return out
@@ -0,0 +1,463 @@
1
+ import unittest
2
+
3
+ from termrender.renderers.mermaid_sequence import SequenceDiagramError, render_sequence
4
+
5
+
6
+ class TestNotASequenceDiagram(unittest.TestCase):
7
+
8
+ def test_flowchart_source_raises(self):
9
+ with self.assertRaises(SequenceDiagramError):
10
+ render_sequence("graph TD\n A-->B", 80)
11
+
12
+ def test_pie_source_raises(self):
13
+ with self.assertRaises(SequenceDiagramError):
14
+ render_sequence('pie\n "A" : 1', 80)
15
+
16
+ def test_blank_source_raises(self):
17
+ with self.assertRaises(SequenceDiagramError):
18
+ render_sequence("", 80)
19
+
20
+ def test_leading_blank_lines_before_header_are_tolerated(self):
21
+ src = "\n\n sequenceDiagram\n A->>B: hi\n"
22
+ # Should not raise, and should render something.
23
+ lines = render_sequence(src, 80)
24
+ self.assertTrue(any("hi" in line for line in lines))
25
+
26
+
27
+ class TestGoldenBasicArrows(unittest.TestCase):
28
+ """Locks down the canonical two-participant example."""
29
+
30
+ def test_classic_two_participant_exchange(self):
31
+ src = (
32
+ "sequenceDiagram\n"
33
+ " Alice->>Bob: Hello Bob, how are you?\n"
34
+ " Bob-->>Alice: Great!\n"
35
+ )
36
+ expected = [
37
+ "┌───────┐ ┌─────┐",
38
+ "│ Alice │ │ Bob │",
39
+ "└───────┘ └─────┘",
40
+ " │ │",
41
+ " Hello Bob, how are you?",
42
+ " ─────────────────────────>",
43
+ " │ │",
44
+ " Great!",
45
+ " <╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌",
46
+ " │ │",
47
+ "┌───────┐ ┌─────┐",
48
+ "│ Alice │ │ Bob │",
49
+ "└───────┘ └─────┘",
50
+ ]
51
+ self.assertEqual(render_sequence(src, 80), expected)
52
+
53
+ def test_self_message_golden(self):
54
+ src = "sequenceDiagram\n participant A\n A->>A: Think\n"
55
+ expected = [
56
+ "┌───┐",
57
+ "│ A │",
58
+ "└───┘",
59
+ " │",
60
+ " ─╮",
61
+ " ││ Think",
62
+ " <╯",
63
+ " │",
64
+ "┌───┐",
65
+ "│ A │",
66
+ "└───┘",
67
+ ]
68
+ self.assertEqual(render_sequence(src, 80), expected)
69
+
70
+ def test_autonumber_golden(self):
71
+ src = (
72
+ "sequenceDiagram\n"
73
+ " autonumber\n"
74
+ " participant A\n"
75
+ " participant B\n"
76
+ " A->>B: one\n"
77
+ " B->>A: two\n"
78
+ )
79
+ expected = [
80
+ "┌───┐ ┌───┐",
81
+ "│ A │ │ B │",
82
+ "└───┘ └───┘",
83
+ " │ │",
84
+ " 1: one",
85
+ " ────────>",
86
+ " │ │",
87
+ " 2: two",
88
+ " <────────",
89
+ " │ │",
90
+ "┌───┐ ┌───┐",
91
+ "│ A │ │ B │",
92
+ "└───┘ └───┘",
93
+ ]
94
+ self.assertEqual(render_sequence(src, 80), expected)
95
+
96
+
97
+ class TestArrowVariants(unittest.TestCase):
98
+ """All 8 arrow forms must be visually distinct: solid vs dashed line,
99
+ and a distinct arrowhead glyph per marker type."""
100
+
101
+ def _arrow_lines(self, arrow: str) -> list[str]:
102
+ src = f"sequenceDiagram\n participant A\n participant B\n A{arrow}B: x\n"
103
+ lines = render_sequence(src, 80)
104
+ # The arrow line has a horizontal connector but is not a box border
105
+ # (box borders also contain \u2500/\u254c via their corners).
106
+ return [
107
+ l
108
+ for l in lines
109
+ if ("\u2500" in l or "\u254c" in l) and "\u250c" not in l and "\u2514" not in l
110
+ ]
111
+
112
+ def test_solid_open(self):
113
+ lines = self._arrow_lines("->")
114
+ self.assertTrue(any(l.rstrip().endswith("\u203a") for l in lines)) # ›
115
+ self.assertTrue(any("\u2500" in l for l in lines))
116
+ self.assertFalse(any("\u254c" in l for l in lines))
117
+
118
+ def test_dashed_open(self):
119
+ lines = self._arrow_lines("-->")
120
+ self.assertTrue(any(l.rstrip().endswith("\u203a") for l in lines))
121
+ self.assertTrue(any("\u254c" in l for l in lines))
122
+
123
+ def test_solid_filled(self):
124
+ lines = self._arrow_lines("->>")
125
+ self.assertTrue(any(l.rstrip().endswith(">") for l in lines))
126
+ self.assertTrue(any("\u2500" in l for l in lines))
127
+ self.assertFalse(any("\u254c" in l for l in lines))
128
+
129
+ def test_dashed_filled(self):
130
+ lines = self._arrow_lines("-->>")
131
+ self.assertTrue(any(l.rstrip().endswith(">") for l in lines))
132
+ self.assertTrue(any("\u254c" in l for l in lines))
133
+
134
+ def test_solid_lost(self):
135
+ lines = self._arrow_lines("-x")
136
+ self.assertTrue(any(l.rstrip().endswith("\u2717") for l in lines)) # ✗
137
+ self.assertFalse(any("\u254c" in l for l in lines))
138
+
139
+ def test_dashed_lost(self):
140
+ lines = self._arrow_lines("--x")
141
+ self.assertTrue(any(l.rstrip().endswith("\u2717") for l in lines))
142
+ self.assertTrue(any("\u254c" in l for l in lines))
143
+
144
+ def test_solid_async(self):
145
+ lines = self._arrow_lines("-)")
146
+ self.assertTrue(any(l.rstrip().endswith(")") for l in lines))
147
+ self.assertFalse(any("\u254c" in l for l in lines))
148
+
149
+ def test_dashed_async(self):
150
+ lines = self._arrow_lines("--)")
151
+ self.assertTrue(any(l.rstrip().endswith(")") for l in lines))
152
+ self.assertTrue(any("\u254c" in l for l in lines))
153
+
154
+ def test_reverse_direction_arrowhead_points_left(self):
155
+ src = "sequenceDiagram\n participant A\n participant B\n B->>A: back\n"
156
+ lines = render_sequence(src, 80)
157
+ arrow_line = next(
158
+ l for l in lines if "\u2500" in l and "\u250c" not in l and "\u2514" not in l
159
+ )
160
+ self.assertTrue(arrow_line.lstrip().startswith("<"))
161
+
162
+
163
+ class TestParticipantsAndAliases(unittest.TestCase):
164
+
165
+ def test_explicit_participant_and_actor_with_alias(self):
166
+ src = (
167
+ "sequenceDiagram\n"
168
+ " participant C1 as Core (PID 92348)\n"
169
+ " actor U as User\n"
170
+ " C1->>U: ping\n"
171
+ )
172
+ lines = render_sequence(src, 80)
173
+ self.assertTrue(any("Core (PID 92348)" in l for l in lines))
174
+ self.assertTrue(any("User" in l for l in lines))
175
+
176
+ def test_implicit_participants_ordered_by_first_appearance(self):
177
+ src = "sequenceDiagram\n B->>A: hi\n A->>C: hey\n"
178
+ lines = render_sequence(src, 80)
179
+ header = lines[1] # the row with participant labels
180
+ self.assertLess(header.index("B"), header.index("A"))
181
+ self.assertLess(header.index("A"), header.index("C"))
182
+
183
+ def test_implicit_and_explicit_participants_mixed(self):
184
+ src = (
185
+ "sequenceDiagram\n"
186
+ " participant A\n"
187
+ " A->>B: hi\n" # B is implicit
188
+ )
189
+ lines = render_sequence(src, 80)
190
+ self.assertTrue(any("A" in l and "B" in l for l in lines[:3]))
191
+
192
+
193
+ class TestNotes(unittest.TestCase):
194
+
195
+ def test_note_over_two_participants_and_solo_golden(self):
196
+ src = (
197
+ "sequenceDiagram\n"
198
+ " participant A\n"
199
+ " participant B\n"
200
+ " Note over A,B: shared context\n"
201
+ " Note over A: solo\n"
202
+ )
203
+ expected = [
204
+ "┌───┐ ┌───┐",
205
+ "│ A │ │ B │",
206
+ "└───┘ └───┘",
207
+ " │ │",
208
+ "┌────────────────┐",
209
+ "│ shared context │",
210
+ "└────────────────┘",
211
+ " │ │",
212
+ "┌──────┐ │",
213
+ "│ solo │ │",
214
+ "└──────┘ │",
215
+ " │ │",
216
+ "┌───┐ ┌───┐",
217
+ "│ A │ │ B │",
218
+ "└───┘ └───┘",
219
+ ]
220
+ self.assertEqual(render_sequence(src, 80), expected)
221
+
222
+ def test_note_left_of_and_right_of_render_without_crashing(self):
223
+ src = (
224
+ "sequenceDiagram\n"
225
+ " participant A\n"
226
+ " participant B\n"
227
+ " Note left of A: L\n"
228
+ " Note right of B: R\n"
229
+ )
230
+ lines = render_sequence(src, 80)
231
+ self.assertTrue(any("L" in l for l in lines))
232
+ self.assertTrue(any("R" in l for l in lines))
233
+
234
+ def test_note_left_of_first_participant_clamps_instead_of_going_negative(self):
235
+ # Known degradation: overflow to the left of column 0 clamps to 0
236
+ # rather than producing a negative-index crash.
237
+ src = (
238
+ "sequenceDiagram\n"
239
+ " Note left of A: a very long note that would overflow left\n"
240
+ " A->>B: hi\n"
241
+ )
242
+ lines = render_sequence(src, 80) # must not raise
243
+ self.assertTrue(any("overflow left" in l for l in lines))
244
+
245
+
246
+ class TestBrAndActivateTolerated(unittest.TestCase):
247
+
248
+ def test_br_flattened_in_message(self):
249
+ src = "sequenceDiagram\n participant A\n participant B\n A->>B: line1<br/>line2\n"
250
+ lines = render_sequence(src, 80)
251
+ self.assertTrue(any("line1 / line2" in l for l in lines))
252
+ self.assertFalse(any("<br" in l for l in lines))
253
+
254
+ def test_br_flattened_in_note(self):
255
+ src = "sequenceDiagram\n participant A\n Note over A: line1<br/>line2\n"
256
+ lines = render_sequence(src, 80)
257
+ self.assertTrue(any("line1 / line2" in l for l in lines))
258
+
259
+ def test_standalone_activate_deactivate_tolerated(self):
260
+ src = (
261
+ "sequenceDiagram\n"
262
+ " participant A\n"
263
+ " participant B\n"
264
+ " activate A\n"
265
+ " A->>B: hi\n"
266
+ " deactivate A\n"
267
+ )
268
+ lines = render_sequence(src, 80) # must not raise
269
+ self.assertTrue(any("hi" in l for l in lines))
270
+
271
+ def test_arrow_activation_shorthand_tolerated(self):
272
+ src = (
273
+ "sequenceDiagram\n"
274
+ " participant A\n"
275
+ " participant B\n"
276
+ " A->>+B: hi\n"
277
+ " B-->>-A: bye\n"
278
+ )
279
+ lines = render_sequence(src, 80) # must not raise
280
+ self.assertTrue(any("hi" in l for l in lines))
281
+ self.assertTrue(any("bye" in l for l in lines))
282
+
283
+
284
+ class TestBlockConstructs(unittest.TestCase):
285
+
286
+ def test_loop_renders_as_labeled_band(self):
287
+ src = (
288
+ "sequenceDiagram\n"
289
+ " participant A\n"
290
+ " participant B\n"
291
+ " loop every second\n"
292
+ " A->>B: poll\n"
293
+ " end\n"
294
+ )
295
+ lines = render_sequence(src, 80)
296
+ self.assertTrue(any(l.startswith("\u250c\u2500 loop every second") for l in lines))
297
+ self.assertTrue(any(l.startswith("\u2514") for l in lines))
298
+
299
+ def test_alt_else_renders_start_mid_end_bands(self):
300
+ src = (
301
+ "sequenceDiagram\n"
302
+ " participant A\n"
303
+ " participant B\n"
304
+ " alt success\n"
305
+ " A->>B: ok\n"
306
+ " else failure\n"
307
+ " A->>B: fail\n"
308
+ " end\n"
309
+ )
310
+ lines = render_sequence(src, 80)
311
+ self.assertTrue(any("alt success" in l and l.startswith("\u250c") for l in lines))
312
+ self.assertTrue(any("else failure" in l and l.startswith("\u251c") for l in lines))
313
+ self.assertTrue(any(l.startswith("\u2514") for l in lines))
314
+
315
+ def test_nested_loop_and_alt_does_not_crash(self):
316
+ src = (
317
+ "sequenceDiagram\n"
318
+ " participant A\n"
319
+ " participant B\n"
320
+ " loop outer\n"
321
+ " alt inner\n"
322
+ " A->>B: x\n"
323
+ " end\n"
324
+ " end\n"
325
+ )
326
+ lines = render_sequence(src, 80) # must not raise
327
+ self.assertTrue(any("x" in l for l in lines))
328
+
329
+ def test_par_and_renders_bands(self):
330
+ src = (
331
+ "sequenceDiagram\n"
332
+ " participant A\n"
333
+ " participant B\n"
334
+ " par one\n"
335
+ " A->>B: x\n"
336
+ " and two\n"
337
+ " A->>B: y\n"
338
+ " end\n"
339
+ )
340
+ lines = render_sequence(src, 80)
341
+ self.assertTrue(any("par one" in l for l in lines))
342
+ self.assertTrue(any("and two" in l for l in lines))
343
+
344
+
345
+ class TestDegradationCases(unittest.TestCase):
346
+ """Malformed or unrecognized constructs must degrade to plain lines,
347
+ never crash."""
348
+
349
+ def test_stray_else_with_no_open_block_degrades_to_plain_line(self):
350
+ src = (
351
+ "sequenceDiagram\n"
352
+ " participant A\n"
353
+ " else stray\n"
354
+ " A->>A: x\n"
355
+ )
356
+ lines = render_sequence(src, 80) # must not raise
357
+ self.assertTrue(any("else stray" in l for l in lines))
358
+
359
+ def test_stray_end_with_no_open_block_is_ignored(self):
360
+ src = "sequenceDiagram\n participant A\n end\n A->>A: x\n"
361
+ lines = render_sequence(src, 80) # must not raise
362
+ self.assertTrue(any("x" in l for l in lines))
363
+
364
+ def test_unrecognized_line_degrades_to_plain_label(self):
365
+ src = "sequenceDiagram\n participant A\n title Some Title\n A->>A: x\n"
366
+ lines = render_sequence(src, 80) # must not raise
367
+ self.assertTrue(any("Some Title" in l for l in lines))
368
+
369
+ def test_no_participants_no_events_returns_empty(self):
370
+ src = "sequenceDiagram\n"
371
+ self.assertEqual(render_sequence(src, 80), [])
372
+
373
+ def test_comment_lines_ignored(self):
374
+ src = "sequenceDiagram\n %% a comment\n participant A\n A->>A: hi\n"
375
+ lines = render_sequence(src, 80) # must not raise
376
+ self.assertFalse(any("%%" in l for l in lines))
377
+
378
+ def test_flowchart_only_dotted_arrow_does_not_spawn_bogus_participant(self):
379
+ # "-.->"" is a flowchart-only arrow with no sequence-diagram
380
+ # equivalent; it must not be mis-absorbed into an id (e.g. "A-.")
381
+ # by the arrow regex — it should degrade to a plain line instead.
382
+ src = (
383
+ "sequenceDiagram\n"
384
+ " participant A\n"
385
+ " participant B\n"
386
+ " A-.->B: unsupported dotted arrow\n"
387
+ )
388
+ lines = render_sequence(src, 80) # must not raise
389
+ header = lines[1]
390
+ self.assertNotIn("A-.", header)
391
+ self.assertIn("unsupported dotted arrow", "\n".join(lines))
392
+
393
+ def test_unknown_punctuation_arrow_degrades_to_plain_line(self):
394
+ src = (
395
+ "sequenceDiagram\n"
396
+ " participant A\n"
397
+ " participant B\n"
398
+ " A==>>B: also weird\n"
399
+ )
400
+ lines = render_sequence(src, 80) # must not raise
401
+ header = lines[1]
402
+ self.assertNotIn("A==", header)
403
+ self.assertIn("A==>>B: also weird", "\n".join(lines))
404
+
405
+
406
+ class TestMultiHopSpacing(unittest.TestCase):
407
+
408
+ def test_message_spanning_multiple_columns_widens_gaps(self):
409
+ src = (
410
+ "sequenceDiagram\n"
411
+ " participant A\n"
412
+ " participant B\n"
413
+ " participant C\n"
414
+ " A->>C: a rather long message across two hops\n"
415
+ )
416
+ lines = render_sequence(src, 80)
417
+ arrow_line = next(
418
+ l for l in lines if "\u2500" in l and "\u250c" not in l and "\u2514" not in l
419
+ )
420
+ # the arrow line must span at least as wide as the label text needs
421
+ self.assertGreaterEqual(len(arrow_line), len("a rather long message across two hops"))
422
+
423
+ def test_self_message_on_last_participant_does_not_crash(self):
424
+ src = (
425
+ "sequenceDiagram\n"
426
+ " participant A\n"
427
+ " participant B\n"
428
+ " B->>B: last col self loop\n"
429
+ )
430
+ lines = render_sequence(src, 80) # must not raise
431
+ self.assertTrue(any("last col self loop" in l for l in lines))
432
+
433
+
434
+ class TestNoSignificantTrailingWhitespace(unittest.TestCase):
435
+
436
+ def _assert_no_trailing_whitespace(self, src: str) -> None:
437
+ for line in render_sequence(src, 80):
438
+ self.assertEqual(line, line.rstrip(), f"trailing whitespace in: {line!r}")
439
+
440
+ def test_basic_exchange(self):
441
+ self._assert_no_trailing_whitespace(
442
+ "sequenceDiagram\n A->>B: hi\n B-->>A: bye\n"
443
+ )
444
+
445
+ def test_notes_and_blocks(self):
446
+ self._assert_no_trailing_whitespace(
447
+ "sequenceDiagram\n"
448
+ " participant A\n"
449
+ " participant B\n"
450
+ " Note over A,B: n\n"
451
+ " loop x\n"
452
+ " A->>B: y\n"
453
+ " end\n"
454
+ )
455
+
456
+ def test_self_message(self):
457
+ self._assert_no_trailing_whitespace(
458
+ "sequenceDiagram\n participant A\n A->>A: think\n"
459
+ )
460
+
461
+
462
+ if __name__ == "__main__":
463
+ unittest.main()
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