termrender 4.8.0__tar.gz → 4.9.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.8.0 → termrender-4.9.0}/CHANGELOG.md +17 -0
- {termrender-4.8.0 → termrender-4.9.0}/PKG-INFO +1 -1
- termrender-4.9.0/src/termrender/renderers/mermaid_er.py +388 -0
- termrender-4.9.0/tests/test_mermaid_er.py +302 -0
- {termrender-4.8.0 → termrender-4.9.0}/.github/workflows/publish.yml +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/.gitignore +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/CLAUDE.md +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/LICENSE +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/README.md +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/design.json +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/pyproject.toml +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/requirements.json +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/scripts/mermaid_flow_parity.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/__init__.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/__main__.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/blocks.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/emit.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/layout.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/parser.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/py.typed +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/code.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_class.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_layout.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_state.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/table.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/text.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/src/termrender/style.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/__init__.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_charts.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_cli_contract.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_column_alignment.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_diff.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_inline_badge.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_linebreak.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_class.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow_corpus.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow_layout.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow_parser.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_flow_shapes.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_state.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_myst_gaps.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_stat.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_tasklist.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_timeline.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/tests/test_variable_colons.py +0 -0
- {termrender-4.8.0 → termrender-4.9.0}/uv.lock +0 -0
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.9.0 (2026-07-07)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- **mermaid-er**: Native erDiagram renderer
|
|
9
|
+
([`caae993`](https://github.com/crouton-labs/termrender/commit/caae9930b6a996e33b401dc773c5a9aae01b1529))
|
|
10
|
+
|
|
11
|
+
Parses mermaid erDiagram source (entity attribute blocks with PK/FK/UK markers and quoted comments,
|
|
12
|
+
bare entities, the full crow's-foot cardinality grammar in both writing directions, identifying/
|
|
13
|
+
non-identifying relationship lines, entity/attribute aliasing and quoted names) into the shared
|
|
14
|
+
FlowGraph model and hands it to the existing layout_flowgraph engine (compartmented boxes, edge
|
|
15
|
+
labels). Never crashes: unparseable input degrades to a raw echo with no box-drawing glyphs,
|
|
16
|
+
matching mermaid_class.py's contract.
|
|
17
|
+
|
|
18
|
+
Not wired into mermaid.py's dispatcher (later phase). Read-only against mermaid_flow*.py.
|
|
19
|
+
|
|
20
|
+
|
|
4
21
|
## v4.8.0 (2026-07-07)
|
|
5
22
|
|
|
6
23
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.9.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,388 @@
|
|
|
1
|
+
"""Native renderer for mermaid ``erDiagram`` sources.
|
|
2
|
+
|
|
3
|
+
Standalone module: exposes a single pure function, :func:`render_er`. Not
|
|
4
|
+
wired into ``mermaid.py``'s dispatcher (a later, orchestrator-owned phase)
|
|
5
|
+
— nothing in this package imports it. Own parser, own tests, no dependency
|
|
6
|
+
on ``Block`` or the render pipeline, matching the shape of
|
|
7
|
+
``mermaid_class.py`` and ``mermaid_flow.py``.
|
|
8
|
+
|
|
9
|
+
This module does no layout or rasterization of its own. It parses mermaid
|
|
10
|
+
``erDiagram`` source into the *same* :class:`~termrender.renderers.
|
|
11
|
+
mermaid_flow_model.FlowGraph` the native flowchart engine consumes — each
|
|
12
|
+
ER entity becomes a :class:`~termrender.renderers.mermaid_flow_model.
|
|
13
|
+
FlowNode` (a compartmented box when the entity has an attribute block, a
|
|
14
|
+
plain single-label box otherwise) and each ER relationship becomes a
|
|
15
|
+
:class:`~termrender.renderers.mermaid_flow_model.FlowEdge` (a plain,
|
|
16
|
+
headless line — ER cardinality is drawn as edge-label text, not an
|
|
17
|
+
arrowhead glyph family) — then hands the graph to
|
|
18
|
+
:func:`~termrender.renderers.mermaid_flow_layout.layout_flowgraph`, which
|
|
19
|
+
owns grandalf layout, box rasterization, and orthogonal edge routing. See
|
|
20
|
+
that module's docstring for how compartments render.
|
|
21
|
+
|
|
22
|
+
Grammar supported
|
|
23
|
+
------------------
|
|
24
|
+
- Header: ``erDiagram``. Diagram direction is always top-to-bottom
|
|
25
|
+
(mermaid's erDiagram has no ``direction`` statement of its own, unlike
|
|
26
|
+
flowcharts/class diagrams).
|
|
27
|
+
- Entity blocks: ``ENTITY { ... }``, either as a single line
|
|
28
|
+
(``CUSTOMER { string name PK }``) or spanning multiple lines up to a
|
|
29
|
+
standalone ``}``. Each attribute line is ``type name [PK|FK|UK[, ...]]
|
|
30
|
+
["comment"]`` — type and name are required, the key marker(s) and the
|
|
31
|
+
trailing quoted comment are both optional. The comment is parsed (so it
|
|
32
|
+
doesn't corrupt the match) but dropped from the rendered row — see
|
|
33
|
+
*Known degradations*.
|
|
34
|
+
- Bare entity declarations: ``ENTITY`` on its own line, and entities
|
|
35
|
+
referenced only from a relationship line (never given a block) — both
|
|
36
|
+
render as a plain single-label box (no compartments), matching
|
|
37
|
+
``mermaid_class.py``'s bare-class handling.
|
|
38
|
+
- Entity/attribute aliases: ``p[Person]`` (id ``p``, displayed as
|
|
39
|
+
``Person``) and quoted names/ids containing spaces or punctuation
|
|
40
|
+
(``"driver's license"``, ``"Order Item"[OI]``) are recognized wherever
|
|
41
|
+
an entity token or an attribute name appears; the quotes are stripped
|
|
42
|
+
for display, the unquoted text is the graph key.
|
|
43
|
+
- Relationships: ``ENTITY1 <left-card><line><right-card> ENTITY2 :
|
|
44
|
+
label``. ``<line>`` is ``--`` (identifying) or ``..`` (non-identifying);
|
|
45
|
+
identifying relationships render as a solid line, non-identifying as a
|
|
46
|
+
dashed line (:class:`EdgeStyle.DOTTED`, same glyphs the class-diagram
|
|
47
|
+
renderer uses for realization/dependency). ``<left-card>``/
|
|
48
|
+
``<right-card>`` are mermaid's crow's-foot pairs read next to their
|
|
49
|
+
adjacent entity — ``||`` exactly one, ``o|``/``|o`` zero-or-one,
|
|
50
|
+
``}|``/``|{`` one-or-more, ``}o``/``o{`` zero-or-more (both writing
|
|
51
|
+
directions accepted, per mermaid's own grammar) — translated to text
|
|
52
|
+
markers ``1``, ``0..1``, ``1..*``, ``0..*`` and combined with the
|
|
53
|
+
optional ``: label`` into one string (``"1 places 0..*"``), centered on
|
|
54
|
+
the edge by the engine. Relationship lines never draw an arrowhead
|
|
55
|
+
(``dst_arrow``/``src_arrow`` both ``False``) — ER cardinality is
|
|
56
|
+
conveyed entirely by the label text, not by a glyph family, since crow's
|
|
57
|
+
foot notation has no equivalent among the engine's existing arrow-kind
|
|
58
|
+
glyphs (see *Known degradations*).
|
|
59
|
+
- ``%%`` comments are dropped.
|
|
60
|
+
|
|
61
|
+
Never crashes: any unparseable input (missing header, or a parse/layout
|
|
62
|
+
exception) degrades to a **raw echo** of the source lines with no
|
|
63
|
+
box-drawing/geometric glyphs (same contract, same glyph ranges, as
|
|
64
|
+
``mermaid_flow.py`` and ``mermaid_class.py`` — see those modules'
|
|
65
|
+
docstrings for why this exact degradation matters to the crouter attach
|
|
66
|
+
viewer).
|
|
67
|
+
|
|
68
|
+
Known degradations (by design, not bugs)
|
|
69
|
+
-----------------------------------------
|
|
70
|
+
- Cardinality and the relation label are combined into a single string
|
|
71
|
+
centered on the edge (``"1 places 0..*"``), not three independently
|
|
72
|
+
placed annotations (one marker pinned at each endpoint plus a centered
|
|
73
|
+
label, the dedicated-ER-tool convention) — the underlying engine places
|
|
74
|
+
one label per edge, same limitation ``mermaid_class.py`` documents for
|
|
75
|
+
UML cardinalities.
|
|
76
|
+
- No crow's-foot glyphs are drawn at the endpoints; cardinality is text
|
|
77
|
+
only. The engine's arrow-kind extension point supports UML triangle/
|
|
78
|
+
diamond families, not a crow's-foot family, and adding one is out of
|
|
79
|
+
scope for this renderer (an engine change, not a parser change).
|
|
80
|
+
- Attribute comments (the trailing quoted string in an attribute line)
|
|
81
|
+
are parsed — so they don't break the match or leak into the name/key
|
|
82
|
+
columns — but dropped entirely from the rendered row rather than
|
|
83
|
+
wrapped or truncated onto a second line.
|
|
84
|
+
- An entity declared with an empty block (``ENTITY { }``) still gets a
|
|
85
|
+
two-compartment box (title + a single blank attribute row) rather than
|
|
86
|
+
collapsing to a plain box — mirrors ``mermaid_class.py``'s
|
|
87
|
+
always-show-the-band-if-a-block-was-declared behavior for classes with
|
|
88
|
+
an empty fields/methods section.
|
|
89
|
+
- See ``mermaid_flow_layout.py``'s docstring for the inherited engine
|
|
90
|
+
degradations (dense-graph crossings, CJK wrap width, minimum-box-size
|
|
91
|
+
cosmetics) — this renderer's output goes through the same rasterizer
|
|
92
|
+
and router as flowcharts and class diagrams.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
from __future__ import annotations
|
|
96
|
+
|
|
97
|
+
import re
|
|
98
|
+
|
|
99
|
+
from termrender.renderers.mermaid_flow_layout import layout_flowgraph
|
|
100
|
+
from termrender.renderers.mermaid_flow_model import (
|
|
101
|
+
Direction,
|
|
102
|
+
EdgeStyle,
|
|
103
|
+
FlowEdge,
|
|
104
|
+
FlowGraph,
|
|
105
|
+
FlowNode,
|
|
106
|
+
NodeShape,
|
|
107
|
+
)
|
|
108
|
+
from termrender.renderers.mermaid_prelude import strip_prelude_lines
|
|
109
|
+
|
|
110
|
+
__all__ = ["render_er", "ERDiagramError"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ERDiagramError(Exception):
|
|
114
|
+
"""Raised when source cannot be parsed as a mermaid ER diagram at all."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------------------------------------
|
|
118
|
+
# Grammar
|
|
119
|
+
# --------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
_HEADER_RE = re.compile(r"^erDiagram\b", re.IGNORECASE)
|
|
122
|
+
_COMMENT_RE = re.compile(r"^%%")
|
|
123
|
+
|
|
124
|
+
# An entity token: a quoted name (optionally aliased, `"Order Item"[OI]`),
|
|
125
|
+
# or a bare run of non-space/non-brace characters (`CUSTOMER`, `p[Person]`,
|
|
126
|
+
# `LINE-ITEM`). Used for bare declarations, block openers, and relationship
|
|
127
|
+
# endpoints alike (see `_parse_entity_token`).
|
|
128
|
+
_ENTITY_TOKEN = r'"[^"]*"(?:\[[^\]]*\])?|[^\s{]+'
|
|
129
|
+
|
|
130
|
+
_ENTITY_TOKEN_RE = re.compile(r'^(?P<id>"[^"]*"|[^\[\s]+)(?:\[(?P<alias>[^\]]*)\])?$')
|
|
131
|
+
_BLOCK_OPEN_RE = re.compile(rf"^(?P<idtok>{_ENTITY_TOKEN})\s*\{{(?P<inline>.*)$")
|
|
132
|
+
_BARE_ENTITY_RE = re.compile(rf"^(?P<idtok>{_ENTITY_TOKEN})\s*$")
|
|
133
|
+
|
|
134
|
+
# Attribute line: `type name [PK|FK|UK[, ...]] ["comment"]`. The comment is
|
|
135
|
+
# matched (so it can't corrupt the name/keys columns) but never rendered.
|
|
136
|
+
_ATTR_RE = re.compile(
|
|
137
|
+
r'^(?P<type>\S+)\s+(?P<name>"[^"]*"|\S+)'
|
|
138
|
+
r"(?:\s+(?P<keys>(?:PK|FK|UK)(?:\s*,\s*(?:PK|FK|UK))*))?"
|
|
139
|
+
r'(?:\s+"[^"]*")?\s*$',
|
|
140
|
+
re.IGNORECASE,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Relationship operator: a left crow's-foot pair, a >=2-run dash/dot core
|
|
144
|
+
# (dash = identifying/solid, dot = non-identifying/dashed), a right
|
|
145
|
+
# crow's-foot pair. Searched against a quote-masked copy of the line (see
|
|
146
|
+
# `_mask_quotes`) so a quoted entity/attribute name can never be mistaken
|
|
147
|
+
# for the operator.
|
|
148
|
+
_LEFT_CARD = r"\|o|\|\||\}o|\}\|"
|
|
149
|
+
_RIGHT_CARD = r"o\||\|\||o\{|\|\{"
|
|
150
|
+
_REL_OP_RE = re.compile(rf"(?P<left>{_LEFT_CARD})(?P<mid>--|\.\.)(?P<right>{_RIGHT_CARD})")
|
|
151
|
+
_REL_LEFT_RE = re.compile(rf"^\s*(?P<idtok>{_ENTITY_TOKEN})\s*$")
|
|
152
|
+
_REL_RIGHT_RE = re.compile(rf"^\s*(?P<idtok>{_ENTITY_TOKEN})\s*(?::\s*(?P<label>.*))?$")
|
|
153
|
+
|
|
154
|
+
_GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
|
|
155
|
+
|
|
156
|
+
# Crow's-foot pair -> cardinality text, read from mermaid's own vocabulary
|
|
157
|
+
# (both writing directions map to the same text on the side they sit next
|
|
158
|
+
# to).
|
|
159
|
+
_CARD_LEFT = {"|o": "0..1", "||": "1", "}o": "0..*", "}|": "1..*"}
|
|
160
|
+
_CARD_RIGHT = {"o|": "0..1", "||": "1", "o{": "0..*", "|{": "1..*"}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _Entity:
|
|
164
|
+
"""Mutable parse-time record for one ER entity — converted to a
|
|
165
|
+
:class:`FlowNode` at the end of parsing (see :func:`_build_graph`)."""
|
|
166
|
+
|
|
167
|
+
__slots__ = ("id", "display_name", "attrs", "has_block")
|
|
168
|
+
|
|
169
|
+
def __init__(self, id_: str, display_name: str) -> None:
|
|
170
|
+
self.id = id_
|
|
171
|
+
self.display_name = display_name
|
|
172
|
+
self.attrs: list[str] = []
|
|
173
|
+
self.has_block = False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _unquote(s: str) -> str:
|
|
177
|
+
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
|
|
178
|
+
return s[1:-1]
|
|
179
|
+
return s
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _mask_quotes(line: str) -> str:
|
|
183
|
+
"""Replace every quoted span with same-length filler so a quoted entity
|
|
184
|
+
or attribute name can never be mistaken for the relation operator when
|
|
185
|
+
searching for it (see `_REL_OP_RE`); positions in the masked string
|
|
186
|
+
stay valid indices into the original line."""
|
|
187
|
+
return re.sub(r'"[^"]*"', lambda m: "Q" * len(m.group(0)), line)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parse_entity_token(token: str) -> tuple[str, str | None]:
|
|
191
|
+
"""Split an entity token into its graph-key id and optional display
|
|
192
|
+
alias: ``CUSTOMER`` -> (``CUSTOMER``, None); ``p[Person]`` -> (``p``,
|
|
193
|
+
``Person``); ``"Order Item"[OI]`` -> (``Order Item``, ``OI``)."""
|
|
194
|
+
m = _ENTITY_TOKEN_RE.match(token.strip())
|
|
195
|
+
if not m:
|
|
196
|
+
return _unquote(token.strip()), None
|
|
197
|
+
alias = m.group("alias")
|
|
198
|
+
return _unquote(m.group("id")), (alias.strip() if alias else None)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _get_or_create(entities: dict[str, _Entity], token: str) -> _Entity:
|
|
202
|
+
eid, alias = _parse_entity_token(token)
|
|
203
|
+
ent = entities.get(eid)
|
|
204
|
+
if ent is None:
|
|
205
|
+
ent = _Entity(eid, alias or eid)
|
|
206
|
+
entities[eid] = ent
|
|
207
|
+
elif alias:
|
|
208
|
+
ent.display_name = alias
|
|
209
|
+
return ent
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _format_attr_row(m: re.Match[str]) -> str:
|
|
213
|
+
row = f"{m.group('type')} {_unquote(m.group('name'))}"
|
|
214
|
+
keys = m.group("keys")
|
|
215
|
+
if keys:
|
|
216
|
+
norm = re.sub(r"\s*,\s*", ", ", keys.strip()).upper()
|
|
217
|
+
row += f" {norm}"
|
|
218
|
+
return row
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _consume_attrs(entity: _Entity, text: str) -> None:
|
|
222
|
+
"""Split ``text`` (one physical line, or the inline body of an
|
|
223
|
+
``ENTITY { ... }`` opener/closer) on ``;`` into individual attribute
|
|
224
|
+
statements and file each into ``entity``. An attribute that doesn't
|
|
225
|
+
match the ``type name [keys] [comment]`` grammar is dropped silently
|
|
226
|
+
(best-effort, never raised)."""
|
|
227
|
+
for part in text.split(";"):
|
|
228
|
+
part = part.strip()
|
|
229
|
+
if not part:
|
|
230
|
+
continue
|
|
231
|
+
m = _ATTR_RE.match(part)
|
|
232
|
+
if m:
|
|
233
|
+
entity.attrs.append(_format_attr_row(m))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _try_relation(
|
|
237
|
+
line: str, entities: dict[str, _Entity], edges: list[FlowEdge]
|
|
238
|
+
) -> bool:
|
|
239
|
+
masked = _mask_quotes(line)
|
|
240
|
+
m = _REL_OP_RE.search(masked)
|
|
241
|
+
if not m:
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
left_text, right_text = line[: m.start()], line[m.end() :]
|
|
245
|
+
lm = _REL_LEFT_RE.match(left_text)
|
|
246
|
+
rm = _REL_RIGHT_RE.match(right_text)
|
|
247
|
+
if not lm or not rm:
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
src = _get_or_create(entities, lm.group("idtok"))
|
|
251
|
+
dst = _get_or_create(entities, rm.group("idtok"))
|
|
252
|
+
|
|
253
|
+
style = EdgeStyle.DOTTED if m.group("mid") == ".." else EdgeStyle.SOLID
|
|
254
|
+
src_card = _CARD_LEFT.get(m.group("left"))
|
|
255
|
+
dst_card = _CARD_RIGHT.get(m.group("right"))
|
|
256
|
+
label_text = rm.group("label")
|
|
257
|
+
parts = [p for p in (src_card, label_text, dst_card) if p]
|
|
258
|
+
label = " ".join(parts) if parts else None
|
|
259
|
+
|
|
260
|
+
edges.append(
|
|
261
|
+
FlowEdge(
|
|
262
|
+
src=src.id,
|
|
263
|
+
dst=dst.id,
|
|
264
|
+
style=style,
|
|
265
|
+
label=label,
|
|
266
|
+
dst_arrow=False,
|
|
267
|
+
src_arrow=False,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _build_graph(source: str) -> FlowGraph:
|
|
274
|
+
lines = source.splitlines()
|
|
275
|
+
sniff_lines = strip_prelude_lines(lines)
|
|
276
|
+
first = next((line.strip() for line in sniff_lines if line.strip()), "")
|
|
277
|
+
if not _HEADER_RE.match(first):
|
|
278
|
+
raise ERDiagramError(
|
|
279
|
+
"not a mermaid ER diagram: source must start with 'erDiagram'"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
entities: dict[str, _Entity] = {}
|
|
283
|
+
edges: list[FlowEdge] = []
|
|
284
|
+
seen_header = False
|
|
285
|
+
block_entity: _Entity | None = None
|
|
286
|
+
|
|
287
|
+
for raw_line in lines:
|
|
288
|
+
line = raw_line.strip()
|
|
289
|
+
if not line:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
if block_entity is not None:
|
|
293
|
+
if line.endswith("}"):
|
|
294
|
+
_consume_attrs(block_entity, line[:-1])
|
|
295
|
+
block_entity = None
|
|
296
|
+
else:
|
|
297
|
+
_consume_attrs(block_entity, line)
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
if not seen_header:
|
|
301
|
+
if _HEADER_RE.match(line):
|
|
302
|
+
seen_header = True
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
if _COMMENT_RE.match(line):
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
om = _BLOCK_OPEN_RE.match(line)
|
|
309
|
+
if om:
|
|
310
|
+
ent = _get_or_create(entities, om.group("idtok"))
|
|
311
|
+
ent.has_block = True
|
|
312
|
+
body = om.group("inline").rstrip()
|
|
313
|
+
if body.endswith("}"):
|
|
314
|
+
_consume_attrs(ent, body[:-1])
|
|
315
|
+
else:
|
|
316
|
+
_consume_attrs(ent, body)
|
|
317
|
+
block_entity = ent
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
if _try_relation(line, entities, edges):
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
bm = _BARE_ENTITY_RE.match(line)
|
|
324
|
+
if bm:
|
|
325
|
+
_get_or_create(entities, bm.group("idtok"))
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
# Unrecognized line: consumed silently, best-effort.
|
|
329
|
+
|
|
330
|
+
if not seen_header:
|
|
331
|
+
raise ERDiagramError(
|
|
332
|
+
"not a mermaid ER diagram: source must start with 'erDiagram'"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
nodes: list[FlowNode] = []
|
|
336
|
+
for ent in entities.values():
|
|
337
|
+
compartments = [[ent.display_name], ent.attrs or [""]] if ent.has_block else None
|
|
338
|
+
nodes.append(
|
|
339
|
+
FlowNode(
|
|
340
|
+
id=ent.id,
|
|
341
|
+
label=ent.display_name,
|
|
342
|
+
shape=NodeShape.RECT,
|
|
343
|
+
compartments=compartments,
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return FlowGraph(direction=Direction.TB, nodes=nodes, edges=edges, subgraphs=[])
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _raw_echo(source: str) -> list[str]:
|
|
351
|
+
return [_GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def render_er(source: str, width: int) -> list[str]:
|
|
355
|
+
"""Render a mermaid ``erDiagram`` source to unicode lines.
|
|
356
|
+
|
|
357
|
+
Pure and total: never raises. Degrades to a raw echo of ``source``
|
|
358
|
+
(see the module docstring's "Never crashes" note) when the source
|
|
359
|
+
isn't an ER diagram at all, parses to zero entities, or an unexpected
|
|
360
|
+
exception escapes layout.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
source: The mermaid fence body (with or without surrounding fence
|
|
364
|
+
markers — only the text between them).
|
|
365
|
+
width: Advisory terminal width — unused, like the flowchart and
|
|
366
|
+
class-diagram renderers; this function sizes to content.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Rendered lines on success (guaranteed to contain box-drawing
|
|
370
|
+
glyphs), or a raw echo of ``source`` on any degradation path.
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
graph = _build_graph(source)
|
|
374
|
+
except ERDiagramError:
|
|
375
|
+
return _raw_echo(source)
|
|
376
|
+
|
|
377
|
+
if not graph.nodes:
|
|
378
|
+
return _raw_echo(source)
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
lines = layout_flowgraph(graph, width)
|
|
382
|
+
except Exception:
|
|
383
|
+
return _raw_echo(source)
|
|
384
|
+
|
|
385
|
+
if not lines:
|
|
386
|
+
return _raw_echo(source)
|
|
387
|
+
|
|
388
|
+
return lines
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Golden-output tests for the native mermaid erDiagram renderer.
|
|
2
|
+
|
|
3
|
+
Mirrors ``test_mermaid_class.py``'s methodology: real rendered geometry
|
|
4
|
+
(exact line lists, or topology assertions on a full relation chain), not
|
|
5
|
+
merely "no exception raised". ``render_er`` reuses the flowchart engine's
|
|
6
|
+
grandalf layout + rasterizer + router (via the shared ``FlowGraph``/
|
|
7
|
+
``FlowNode``/``FlowEdge`` model — see ``mermaid_er.py``'s module
|
|
8
|
+
docstring), so these tests exercise the ER-specific parsing (attribute
|
|
9
|
+
blocks, cardinality-to-text mapping, aliasing) end to end rather than
|
|
10
|
+
re-testing the underlying engine (already covered by
|
|
11
|
+
``test_mermaid_flow_corpus.py``/``test_mermaid_flow_shapes.py``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from termrender.renderers.mermaid_er import render_er
|
|
19
|
+
|
|
20
|
+
_BOX_GLYPH_RE = re.compile(r"[\u2500-\u259F\u25A0-\u25FF]")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _lines(source: str, width: int = 80) -> list[str]:
|
|
24
|
+
return render_er(source, width)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _has_box_glyphs(lines: list[str]) -> bool:
|
|
28
|
+
return any(_BOX_GLYPH_RE.search(line) for line in lines)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _row_col(lines: list[str], glyph: str) -> tuple[int, int]:
|
|
32
|
+
for r, line in enumerate(lines):
|
|
33
|
+
c = line.find(glyph)
|
|
34
|
+
if c != -1:
|
|
35
|
+
return r, c
|
|
36
|
+
raise AssertionError(f"glyph {glyph!r} not found in {lines!r}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------------------
|
|
40
|
+
# Compartmented entities
|
|
41
|
+
# --------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_multiline_entity_block_compartments():
|
|
45
|
+
src = (
|
|
46
|
+
"erDiagram\n"
|
|
47
|
+
"CUSTOMER {\n"
|
|
48
|
+
" string name\n"
|
|
49
|
+
" string custNumber\n"
|
|
50
|
+
" string sector\n"
|
|
51
|
+
"}\n"
|
|
52
|
+
)
|
|
53
|
+
assert _lines(src) == [
|
|
54
|
+
"┌───────────────────┐",
|
|
55
|
+
"│ CUSTOMER │",
|
|
56
|
+
"├───────────────────┤",
|
|
57
|
+
"│ string name │",
|
|
58
|
+
"│ string custNumber │",
|
|
59
|
+
"│ string sector │",
|
|
60
|
+
"└───────────────────┘",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_oneline_semicolon_entity_block():
|
|
65
|
+
src = "erDiagram\nENTITY { string name PK; int qty }\n"
|
|
66
|
+
lines = _lines(src)
|
|
67
|
+
assert lines[0].startswith("┌")
|
|
68
|
+
assert any("name PK" in line for line in lines)
|
|
69
|
+
assert any("qty" in line for line in lines)
|
|
70
|
+
sep_rows = [i for i, l in enumerate(lines) if set(l.strip()) <= {"├", "─", "┤"}]
|
|
71
|
+
assert len(sep_rows) == 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_bare_entity_no_block_is_plain_box_not_compartmented():
|
|
75
|
+
assert _lines("erDiagram\nFOO\n") == [
|
|
76
|
+
"┌─────┐",
|
|
77
|
+
"│ FOO │",
|
|
78
|
+
"└─────┘",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_entity_referenced_only_in_relationship_is_plain_box():
|
|
83
|
+
lines = _lines("erDiagram\nA ||--o{ B : has\n")
|
|
84
|
+
# Neither A nor B ever got a `{ ... }` block -> both are plain boxes
|
|
85
|
+
# (no separator rows anywhere in the output).
|
|
86
|
+
sep_rows = [i for i, l in enumerate(lines) if set(l.strip()) <= {"├", "─", "┤"}]
|
|
87
|
+
assert sep_rows == []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_entity_with_empty_block_still_shows_blank_attribute_row():
|
|
91
|
+
src = "erDiagram\nFOO {\n}\n"
|
|
92
|
+
lines = _lines(src)
|
|
93
|
+
sep_rows = [i for i, l in enumerate(lines) if set(l.strip()) <= {"├", "─", "┤"}]
|
|
94
|
+
assert len(sep_rows) == 1
|
|
95
|
+
assert any(l.strip("│ ") == "" for l in lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_attribute_comment_is_dropped_but_does_not_corrupt_parse():
|
|
99
|
+
src = 'erDiagram\nFOO {\n string code PK "a helpful comment"\n}\n'
|
|
100
|
+
lines = _lines(src)
|
|
101
|
+
assert any("code PK" in line for line in lines)
|
|
102
|
+
assert not any("helpful comment" in line for line in lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------
|
|
106
|
+
# Aliases / quoted names
|
|
107
|
+
# --------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_entity_alias_bracket_form_displays_alias_not_id():
|
|
111
|
+
lines = _lines("erDiagram\np[Person] {\n string name\n}\n")
|
|
112
|
+
assert any("Person" in line for line in lines)
|
|
113
|
+
assert not any("p[Person]" in line for line in lines)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_alias_declared_once_resolves_same_node_from_relation():
|
|
117
|
+
src = "erDiagram\np[Person] {\n string name\n}\np ||--o{ CAR : owns\n"
|
|
118
|
+
lines = _lines(src)
|
|
119
|
+
assert sum(1 for line in lines if "Person" in line) == 1
|
|
120
|
+
assert any("CAR" in line for line in lines)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_quoted_attribute_name_with_apostrophe():
|
|
124
|
+
src = 'erDiagram\nPERSON {\n string "driver\'s license" UK\n}\n'
|
|
125
|
+
lines = _lines(src)
|
|
126
|
+
assert any("driver's license" in line for line in lines)
|
|
127
|
+
assert not any('"' in line for line in lines)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_quoted_entity_name_with_alias():
|
|
131
|
+
src = 'erDiagram\n"Order Item"[OI] {\n int qty\n}\n'
|
|
132
|
+
lines = _lines(src)
|
|
133
|
+
assert any("OI" in line for line in lines)
|
|
134
|
+
assert not any("Order Item" in line for line in lines)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------------------
|
|
138
|
+
# Cardinality grammar (all four kinds, both writing directions) + line style
|
|
139
|
+
# --------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_exactly_one_cardinality_both_sides():
|
|
143
|
+
lines = _lines("erDiagram\nA ||--|| B : rel\n")
|
|
144
|
+
assert any("1 rel 1" in line for line in lines)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_zero_or_one_left_and_right_forms():
|
|
148
|
+
left = _lines("erDiagram\nA |o--|| B : rel\n")
|
|
149
|
+
right = _lines("erDiagram\nA ||--o| B : rel\n")
|
|
150
|
+
assert any("0..1 rel 1" in line for line in left)
|
|
151
|
+
assert any("1 rel 0..1" in line for line in right)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_one_or_more_left_and_right_forms():
|
|
155
|
+
left = _lines("erDiagram\nA }|--|| B : rel\n")
|
|
156
|
+
right = _lines("erDiagram\nA ||--|{ B : rel\n")
|
|
157
|
+
assert any("1..* rel 1" in line for line in left)
|
|
158
|
+
assert any("1 rel 1..*" in line for line in right)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_zero_or_more_left_and_right_forms():
|
|
162
|
+
left = _lines("erDiagram\nA }o--|| B : rel\n")
|
|
163
|
+
right = _lines("erDiagram\nA ||--o{ B : rel\n")
|
|
164
|
+
assert any("0..* rel 1" in line for line in left)
|
|
165
|
+
assert any("1 rel 0..*" in line for line in right)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_cardinality_without_label():
|
|
169
|
+
lines = _lines("erDiagram\nCUSTOMER ||--o{ ORDER\n")
|
|
170
|
+
assert any("1 0..*" in line for line in lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_identifying_relation_is_solid_line():
|
|
174
|
+
lines = _lines("erDiagram\nA ||--o{ B : has\n")
|
|
175
|
+
joined = "\n".join(lines)
|
|
176
|
+
assert "│" in joined
|
|
177
|
+
assert "╎" not in joined
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_non_identifying_relation_is_dashed_line():
|
|
181
|
+
lines = _lines("erDiagram\nA ||..o{ B : has\n")
|
|
182
|
+
joined = "\n".join(lines)
|
|
183
|
+
assert "╎" in joined
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_relationship_never_draws_an_arrowhead():
|
|
187
|
+
lines = _lines("erDiagram\nA ||--o{ B : has\n")
|
|
188
|
+
joined = "\n".join(lines)
|
|
189
|
+
for glyph in ("▲", "▼", "◀", "▶"):
|
|
190
|
+
assert glyph not in joined
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --------------------------------------------------------------------------
|
|
194
|
+
# Full topology: a small multi-entity chain
|
|
195
|
+
# --------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_multi_entity_chain_topology():
|
|
199
|
+
src = "erDiagram\nA ||--o{ B : ab\nB ||--o{ C : bc\n"
|
|
200
|
+
lines = _lines(src)
|
|
201
|
+
assert _has_box_glyphs(lines)
|
|
202
|
+
for name in "ABC":
|
|
203
|
+
assert any(name == line.strip("│ ") for line in lines)
|
|
204
|
+
a_row, _ = _row_col(lines, "A")
|
|
205
|
+
b_row, _ = _row_col(lines, "B")
|
|
206
|
+
c_row, _ = _row_col(lines, "C")
|
|
207
|
+
assert a_row < b_row < c_row # linear TB chain, declaration order
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# --------------------------------------------------------------------------
|
|
211
|
+
# Golden corpus: mermaid docs' classic order example
|
|
212
|
+
# --------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_order_example_golden_topology():
|
|
216
|
+
src = (
|
|
217
|
+
"erDiagram\n"
|
|
218
|
+
" CUSTOMER ||--o{ ORDER : places\n"
|
|
219
|
+
" ORDER ||--|{ LINE-ITEM : contains\n"
|
|
220
|
+
" CUSTOMER }|..|{ DELIVERY-ADDRESS : uses\n"
|
|
221
|
+
"\n"
|
|
222
|
+
" CUSTOMER {\n"
|
|
223
|
+
" string name\n"
|
|
224
|
+
" string custNumber\n"
|
|
225
|
+
" string sector\n"
|
|
226
|
+
" }\n"
|
|
227
|
+
" ORDER {\n"
|
|
228
|
+
" int orderNumber\n"
|
|
229
|
+
" string deliveryAddress\n"
|
|
230
|
+
" }\n"
|
|
231
|
+
" LINE-ITEM {\n"
|
|
232
|
+
" string productCode\n"
|
|
233
|
+
" int quantity\n"
|
|
234
|
+
" float pricePerUnit\n"
|
|
235
|
+
" }\n"
|
|
236
|
+
)
|
|
237
|
+
lines = _lines(src)
|
|
238
|
+
assert _has_box_glyphs(lines)
|
|
239
|
+
joined = "\n".join(lines)
|
|
240
|
+
|
|
241
|
+
for name in ("CUSTOMER", "ORDER", "LINE-ITEM", "DELIVERY-ADDRESS"):
|
|
242
|
+
assert name in joined
|
|
243
|
+
|
|
244
|
+
# DELIVERY-ADDRESS never got a block -> plain box; the other three did.
|
|
245
|
+
da_row, da_col = _row_col(lines, "DELIVERY-ADDRESS")
|
|
246
|
+
assert lines[da_row][da_col - 2] == "│" # ` DELIVERY-ADDRESS ` inside a border
|
|
247
|
+
|
|
248
|
+
for attr in ("string name", "string custNumber", "string sector"):
|
|
249
|
+
assert attr in joined
|
|
250
|
+
for attr in ("int orderNumber", "string deliveryAddress"):
|
|
251
|
+
assert attr in joined
|
|
252
|
+
for attr in ("string productCode", "int quantity", "float pricePerUnit"):
|
|
253
|
+
assert attr in joined
|
|
254
|
+
|
|
255
|
+
assert "places" in joined
|
|
256
|
+
assert "contains" in joined
|
|
257
|
+
assert "uses" in joined
|
|
258
|
+
|
|
259
|
+
# Non-identifying relation (CUSTOMER }|..|{ DELIVERY-ADDRESS) is dashed.
|
|
260
|
+
assert "╎" in joined
|
|
261
|
+
|
|
262
|
+
cust_row, _ = _row_col(lines, "CUSTOMER")
|
|
263
|
+
order_row, _ = _row_col(lines, "ORDER")
|
|
264
|
+
line_item_row, _ = _row_col(lines, "LINE-ITEM")
|
|
265
|
+
assert cust_row < order_row < line_item_row
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --------------------------------------------------------------------------
|
|
269
|
+
# Degradation: malformed input, empty body, literal-glyph sanitization
|
|
270
|
+
# --------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_non_er_diagram_source_degrades_to_raw_echo():
|
|
274
|
+
src = "graph TD\nA-->B\n"
|
|
275
|
+
lines = _lines(src)
|
|
276
|
+
assert lines == ["graph TD", "A-->B"]
|
|
277
|
+
assert not _has_box_glyphs(lines)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_header_only_empty_body_degrades_to_raw_echo():
|
|
281
|
+
lines = _lines("erDiagram\n")
|
|
282
|
+
assert lines == ["erDiagram"]
|
|
283
|
+
assert not _has_box_glyphs(lines)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_malformed_input_with_literal_box_glyph_is_sanitized():
|
|
287
|
+
src = "not an er diagram\nhas a \u2500 literal glyph\n"
|
|
288
|
+
lines = _lines(src)
|
|
289
|
+
assert lines == ["not an er diagram", "has a ? literal glyph"]
|
|
290
|
+
assert not _has_box_glyphs(lines)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_render_er_never_raises_on_garbage():
|
|
294
|
+
for garbage in (
|
|
295
|
+
"",
|
|
296
|
+
"\n\n\n",
|
|
297
|
+
"erDiagram\nFOO {{{\n",
|
|
298
|
+
"erDiagram\n}}}}\n",
|
|
299
|
+
"erDiagram\nA ||-- B\n", # missing right cardinality pair
|
|
300
|
+
'erDiagram\nA { string "unterminated PK\n',
|
|
301
|
+
):
|
|
302
|
+
render_er(garbage, 80) # must not raise
|
|
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
|
|
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
|