zombie-mermaid 2.2.1 → 2.2.3
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.
- package/LICENSE +1 -0
- package/README.md +7 -2
- package/dist/ascii.cjs +9 -9
- package/dist/ascii.cjs.map +1 -1
- package/dist/ascii.js +4072 -3942
- package/dist/ascii.js.map +1 -1
- package/dist/cli.js +6724 -6464
- package/dist/index.cjs +52 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +7653 -7469
- package/dist/index.js.map +1 -1
- package/dist/mcp.cjs +14201 -13705
- package/dist/mcp.cjs.map +1 -1
- package/dist/mcp.js +14201 -13705
- package/dist/mcp.js.map +1 -1
- package/package.json +28 -6
- package/packages/ascii-renderer/LICENSE +22 -0
- package/{src/ascii → packages/ascii-renderer/src}/class-diagram.ts +469 -69
- package/{src/ascii → packages/ascii-renderer/src}/er-diagram.ts +2 -2
- package/packages/ascii-renderer/src/flowchart.ts +94 -0
- package/{src/ascii → packages/ascii-renderer/src}/grid.ts +23 -0
- package/{src/ascii → packages/ascii-renderer/src}/index.ts +13 -84
- package/{src/ascii → packages/ascii-renderer/src}/registry.ts +23 -12
- package/{src/ascii → packages/ascii-renderer/src}/sequence.ts +89 -8
- package/{src/ascii → packages/ascii-renderer/src}/xychart.ts +6 -3
- package/packages/core/LICENSE +22 -0
- package/packages/core/src/click-directive.ts +106 -0
- package/packages/core/src/color-utils.ts +205 -0
- package/packages/core/src/diagram-type.ts +35 -0
- package/packages/core/src/direction-override.ts +46 -0
- package/packages/core/src/direction.ts +56 -0
- package/packages/core/src/index.ts +34 -0
- package/packages/core/src/init-directive.ts +248 -0
- package/packages/core/src/multiline-utils.ts +275 -0
- package/packages/core/src/statements.ts +197 -0
- package/packages/core/src/style-directives.ts +214 -0
- package/packages/core/src/text-metrics.ts +498 -0
- package/packages/core/src/theme.ts +696 -0
- package/packages/core/src/types.ts +670 -0
- package/packages/mcp/LICENSE +22 -0
- package/{src/mcp → packages/mcp/src}/server.ts +34 -2
- package/{src/mcp → packages/mcp/src}/tools/check-sequence-activations.ts +8 -5
- package/packages/mcp/src/tools/fix-sequence-activations.ts +77 -0
- package/{src/mcp → packages/mcp/src}/tools/render-ascii.ts +2 -2
- package/{src/mcp → packages/mcp/src}/tools/render-svg.ts +1 -1
- package/packages/mermaid-parser/LICENSE +22 -0
- package/{src → packages/mermaid-parser/src}/class/parser.ts +86 -8
- package/{src → packages/mermaid-parser/src}/er/parser.ts +59 -26
- package/packages/mermaid-parser/src/index.ts +48 -0
- package/{src → packages/mermaid-parser/src}/sequence/activation-check.ts +4 -3
- package/packages/mermaid-parser/src/sequence/activation-fix.ts +112 -0
- package/{src → packages/mermaid-parser/src}/sequence/parser.ts +74 -16
- package/{src → packages/mermaid-parser/src}/xychart/parser.ts +84 -6
- package/packages/svg-renderer/LICENSE +22 -0
- package/{src → packages/svg-renderer/src}/class/layout.ts +8 -4
- package/{src → packages/svg-renderer/src}/class/renderer.ts +7 -6
- package/packages/svg-renderer/src/edge-curves.ts +204 -0
- package/packages/svg-renderer/src/elk-instance.ts +292 -0
- package/{src → packages/svg-renderer/src}/er/layout.ts +6 -4
- package/{src → packages/svg-renderer/src}/er/renderer.ts +4 -4
- package/packages/svg-renderer/src/index.ts +58 -0
- package/packages/svg-renderer/src/layout-engine/constants.ts +19 -0
- package/packages/svg-renderer/src/layout-engine/edge-bundling.ts +379 -0
- package/packages/svg-renderer/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/packages/svg-renderer/src/layout-engine/elk-graph-builder.ts +240 -0
- package/packages/svg-renderer/src/layout-engine/from-elk.ts +685 -0
- package/packages/svg-renderer/src/layout-engine/layer-alignment.ts +174 -0
- package/packages/svg-renderer/src/layout-engine/to-elk.ts +695 -0
- package/packages/svg-renderer/src/layout-engine.ts +74 -0
- package/packages/svg-renderer/src/layout.ts +8 -0
- package/packages/svg-renderer/src/renderer.ts +1485 -0
- package/packages/svg-renderer/src/resolve-colors.ts +339 -0
- package/{src → packages/svg-renderer/src}/sequence/layout.ts +2 -6
- package/{src → packages/svg-renderer/src}/sequence/renderer.ts +4 -4
- package/packages/svg-renderer/src/shape-clipping.ts +197 -0
- package/packages/svg-renderer/src/styles.ts +118 -0
- package/{src → packages/svg-renderer/src}/xychart/layout.ts +2 -2
- package/{src → packages/svg-renderer/src}/xychart/renderer.ts +6 -6
- package/src/__tests__/ascii-ansi.test.ts +6 -2
- package/src/__tests__/ascii-canvas-write.test.ts +2 -2
- package/src/__tests__/ascii-charset-border-junctions.test.ts +1 -1
- package/src/__tests__/ascii-cjk-width.test.ts +1 -1
- package/src/__tests__/ascii-class-box-occupancy.test.ts +194 -53
- package/src/__tests__/ascii-class-column-width-488-489.test.ts +1 -1
- package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
- package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +9 -9
- package/src/__tests__/ascii-class-diagram-compartments.test.ts +1 -1
- package/src/__tests__/ascii-class-label-box-collision.test.ts +1 -1
- package/src/__tests__/ascii-class-label-row-collision-531.test.ts +1 -1
- package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +1 -1
- package/src/__tests__/ascii-class-padding.test.ts +1 -1
- package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
- package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
- package/src/__tests__/ascii-class-reciprocal-relationships-448.test.ts +86 -1
- package/src/__tests__/ascii-combining-mark-width.test.ts +2 -2
- package/src/__tests__/ascii-coords-overlay.test.ts +2 -2
- package/src/__tests__/ascii-decision-lr-box-start.test.ts +2 -2
- package/src/__tests__/ascii-display-width-unit.test.ts +1 -1
- package/src/__tests__/ascii-draw-arrows-coverage.test.ts +1 -1
- package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +12 -5
- package/src/__tests__/ascii-draw-bundles.test.ts +5 -5
- package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +1 -1
- package/src/__tests__/ascii-edge-bundling-routing.test.ts +9 -5
- package/src/__tests__/ascii-edge-cross-style-overlap.test.ts +10 -6
- package/src/__tests__/ascii-edge-ending-glyphs.test.ts +1 -1
- package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +2 -2
- package/src/__tests__/ascii-edge-routing-fixes.test.ts +1 -1
- package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +8 -4
- package/src/__tests__/ascii-edge-styles.test.ts +1 -1
- package/src/__tests__/ascii-emoji-cluster-width.test.ts +2 -2
- package/src/__tests__/ascii-er-box-occupancy.test.ts +1 -1
- package/src/__tests__/ascii-er-cardinality.test.ts +1 -1
- package/src/__tests__/ascii-er-corner-glyphs.test.ts +1 -1
- package/src/__tests__/ascii-er-jog-stray-line.test.ts +1 -1
- package/src/__tests__/ascii-er-label-padding.test.ts +1 -1
- package/src/__tests__/ascii-er-padding.test.ts +1 -1
- package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +1 -1
- package/src/__tests__/ascii-er-relationship-overwrite.test.ts +1 -1
- package/src/__tests__/ascii-er-stray-connectors.test.ts +7 -4
- package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +1 -1
- package/src/__tests__/ascii-er-vertical-one-marker.test.ts +1 -1
- package/src/__tests__/ascii-grid-deep-chain.test.ts +4 -4
- package/src/__tests__/ascii-hyperlinks.test.ts +9 -6
- package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +8 -4
- package/src/__tests__/ascii-lane-search.test.ts +1 -1
- package/src/__tests__/ascii-multibox-cjk-width.test.ts +1 -1
- package/src/__tests__/ascii-multiline.test.ts +6 -3
- package/src/__tests__/ascii-nested-subgraph-sibling-order-444.test.ts +8 -5
- package/src/__tests__/ascii-package-boundary.test.ts +47 -37
- package/src/__tests__/ascii-padding-edge-cases.test.ts +1 -1
- package/src/__tests__/ascii-parallel-edges-329.test.ts +8 -5
- package/src/__tests__/ascii-pathfinder-route-edge.test.ts +14 -4
- package/src/__tests__/ascii-reverse-direction-edges-629.test.ts +9 -5
- package/src/__tests__/ascii-sequence-alt-else-label.test.ts +1 -1
- package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +1 -1
- package/src/__tests__/ascii-sequence-box-group.test.ts +1 -1
- package/src/__tests__/ascii-sequence-cjk-width.test.ts +1 -1
- package/src/__tests__/ascii-sequence-create-destroy.test.ts +1 -1
- package/src/__tests__/ascii-sequence-form-invariants.test.ts +99 -50
- package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +1 -1
- package/src/__tests__/ascii-sequence-notes.test.ts +1 -1
- package/src/__tests__/ascii-sequence-padding.test.ts +1 -1
- package/src/__tests__/ascii-sequence-self-arrow.test.ts +1 -1
- package/src/__tests__/ascii-shape-diamond.test.ts +3 -3
- package/src/__tests__/ascii-shape-rectangle.test.ts +5 -5
- package/src/__tests__/ascii-shape-rounded.test.ts +3 -3
- package/src/__tests__/ascii-shapes-circle.test.ts +3 -3
- package/src/__tests__/ascii-shapes-hexagon.test.ts +3 -3
- package/src/__tests__/ascii-shapes-special.test.ts +10 -5
- package/src/__tests__/ascii-shapes-stadium.test.ts +3 -3
- package/src/__tests__/ascii-shapes-state.test.ts +15 -5
- package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +1 -1
- package/src/__tests__/ascii-subgraph-bbox.test.ts +8 -4
- package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +1 -1
- package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +88 -25
- package/src/__tests__/ascii-subgraph-later-edgeless-301.test.ts +8 -4
- package/src/__tests__/ascii-subgraph-title-padding.test.ts +1 -1
- package/src/__tests__/ascii-territory-unit.test.ts +2 -2
- package/src/__tests__/ascii-validate.test.ts +1 -1
- package/src/__tests__/ascii.test.ts +5 -2
- package/src/__tests__/box-color.test.ts +1 -1
- package/src/__tests__/class-arrow-directions.test.ts +1 -1
- package/src/__tests__/class-click-interactions.test.ts +3 -6
- package/src/__tests__/class-integration.test.ts +1 -1
- package/src/__tests__/class-notes.test.ts +3 -3
- package/src/__tests__/class-parser-error-messages.test.ts +162 -0
- package/src/__tests__/class-parser.test.ts +6 -6
- package/src/__tests__/class-styling.test.ts +2 -2
- package/src/__tests__/cli-render.test.ts +16 -1
- package/src/__tests__/direction-override.test.ts +1 -1
- package/src/__tests__/draw-lines.test.ts +2 -2
- package/src/__tests__/edge-cell-styles.test.ts +5 -2
- package/src/__tests__/elk-layout-cache.test.ts +6 -4
- package/src/__tests__/er-click-unsupported.test.ts +5 -8
- package/src/__tests__/er-parser-error-messages.test.ts +82 -0
- package/src/__tests__/er-parser.test.ts +3 -6
- package/src/__tests__/expanded-shapes.test.ts +1 -1
- package/src/__tests__/grid-occupancy.test.ts +1 -1
- package/src/__tests__/mcp-check-sequence-activations-tool.test.ts +1 -1
- package/src/__tests__/mcp-fix-sequence-activations-tool.test.ts +134 -0
- package/src/__tests__/mcp-render-ascii-tool.test.ts +5 -4
- package/src/__tests__/mcp-render-svg-theme-guard.test.ts +3 -3
- package/src/__tests__/mcp-render-svg-tool.test.ts +1 -1
- package/src/__tests__/mcp-server.test.ts +30 -1
- package/src/__tests__/multiline-labels.test.ts +29 -18
- package/src/__tests__/parser.test.ts +117 -5
- package/src/__tests__/pathfinder.test.ts +11 -4
- package/src/__tests__/render-options-overrides.test.ts +28 -21
- package/src/__tests__/render-options-per-type.test.ts +13 -9
- package/src/__tests__/resolve-colors.test.ts +1 -1
- package/src/__tests__/self-loop-edges.test.ts +112 -0
- package/src/__tests__/sequence-activation-check.test.ts +7 -8
- package/src/__tests__/sequence-activation-fix.test.ts +91 -0
- package/src/__tests__/sequence-layout.test.ts +4 -7
- package/src/__tests__/sequence-parser-syntax-errors.test.ts +179 -0
- package/src/__tests__/sequence-parser.test.ts +3 -6
- package/src/__tests__/statements.test.ts +53 -35
- package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +21 -21
- package/src/__tests__/theme-palette-contrast.test.ts +84 -0
- package/src/__tests__/workspace-package-boundaries.test.ts +199 -25
- package/src/__tests__/xychart-ascii.test.ts +1 -1
- package/src/__tests__/xychart-colors.test.ts +3 -2
- package/src/__tests__/xychart-layout.test.ts +1 -1
- package/src/__tests__/xychart-parser-errors.test.ts +95 -0
- package/src/browser.ts +8 -2
- package/src/cli/mcp.ts +1 -1
- package/src/cli/render.ts +59 -25
- package/src/cli/web.ts +1 -1
- package/src/diagram-registry.ts +190 -25
- package/src/index.ts +34 -131
- package/src/parser.ts +102 -40
- /package/{src/ascii → packages/ascii-renderer/src}/ansi.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/canvas.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/converter.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/coords.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/display-width.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/draw-arrows.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/draw-boxes.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/draw-bundles.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/draw-lines.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/draw-subgraphs.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/draw.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/edge-bundling.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/edge-cell-styles.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/edge-routing.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/grid-occupancy.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/hyperlinks.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/lane-search.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/multiline-utils.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/pathfinder.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/circle.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/corners.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/diamond.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/hexagon.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/index.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/rectangle.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/rounded.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/special.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/stadium.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/state.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/shapes/types.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/territory.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/types.ts +0 -0
- /package/{src/ascii → packages/ascii-renderer/src}/validate.ts +0 -0
- /package/{src/mcp → packages/mcp/src}/index.ts +0 -0
- /package/{src → packages/mermaid-parser/src}/class/format.ts +0 -0
- /package/{src → packages/mermaid-parser/src}/class/types.ts +0 -0
- /package/{src → packages/mermaid-parser/src}/er/types.ts +0 -0
- /package/{src → packages/mermaid-parser/src}/expanded-shapes.ts +0 -0
- /package/{src → packages/mermaid-parser/src}/sequence/box-color.ts +0 -0
- /package/{src → packages/mermaid-parser/src}/sequence/types.ts +0 -0
- /package/{src → packages/mermaid-parser/src}/xychart/colors.ts +0 -0
- /package/{src → packages/mermaid-parser/src}/xychart/types.ts +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the sequence diagram activation/deactivation auto-fix
|
|
3
|
+
* (packages/mermaid-parser/src/sequence/activation-fix.ts). Operates on raw
|
|
4
|
+
* Mermaid source text (unlike checkActivationBalance, which takes an
|
|
5
|
+
* already-parsed diagram), since the fix needs to append to the original
|
|
6
|
+
* source rather than just report against the parsed model.
|
|
7
|
+
*
|
|
8
|
+
* Covers: an already-balanced diagram (no-op), a single dangling
|
|
9
|
+
* activation (auto-fixed), multiple dangling activations across actors, an
|
|
10
|
+
* unmatched deactivation (deliberately NOT auto-fixed — see module header),
|
|
11
|
+
* and a mix of both issue types together.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import { fixActivationBalance } from '@zombie-mermaid/mermaid-parser'
|
|
15
|
+
|
|
16
|
+
describe('fixActivationBalance', () => {
|
|
17
|
+
it('returns the diagram unchanged when already balanced', () => {
|
|
18
|
+
const source = 'sequenceDiagram\n A->>+B: hello\n B-->>-A: world'
|
|
19
|
+
const result = fixActivationBalance(source)
|
|
20
|
+
expect(result.ok).toBe(true)
|
|
21
|
+
expect(result.fixedDiagram).toBe(source)
|
|
22
|
+
expect(result.fixesApplied).toEqual([])
|
|
23
|
+
expect(result.remainingIssues).toEqual([])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('appends a deactivate statement for a single dangling activation', () => {
|
|
27
|
+
const source = 'sequenceDiagram\n activate A\n A->>B: hello'
|
|
28
|
+
const result = fixActivationBalance(source)
|
|
29
|
+
expect(result.ok).toBe(true)
|
|
30
|
+
expect(result.originalDiagram).toBe(source)
|
|
31
|
+
expect(result.fixedDiagram).toBe(`${source}\n deactivate A`)
|
|
32
|
+
expect(result.fixesApplied).toEqual([
|
|
33
|
+
expect.stringContaining('Appended "deactivate A"'),
|
|
34
|
+
])
|
|
35
|
+
expect(result.remainingIssues).toEqual([])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('makes the fixed diagram re-check as balanced', () => {
|
|
39
|
+
const source = 'sequenceDiagram\n activate A\n A->>B: hello'
|
|
40
|
+
const { fixedDiagram } = fixActivationBalance(source)
|
|
41
|
+
const reChecked = fixActivationBalance(fixedDiagram)
|
|
42
|
+
expect(reChecked.ok).toBe(true)
|
|
43
|
+
expect(reChecked.fixesApplied).toEqual([])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('closes multiple dangling activations for different actors', () => {
|
|
47
|
+
const source = 'sequenceDiagram\n activate A\n activate B\n A->>B: hello'
|
|
48
|
+
const result = fixActivationBalance(source)
|
|
49
|
+
expect(result.ok).toBe(true)
|
|
50
|
+
expect(result.fixesApplied).toHaveLength(2)
|
|
51
|
+
expect(result.fixedDiagram).toContain(' deactivate A')
|
|
52
|
+
expect(result.fixedDiagram).toContain(' deactivate B')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('does not fix an unmatched deactivation and reports it back', () => {
|
|
56
|
+
const source = 'sequenceDiagram\n A->>B: hello\n deactivate A'
|
|
57
|
+
const result = fixActivationBalance(source)
|
|
58
|
+
expect(result.ok).toBe(false)
|
|
59
|
+
expect(result.fixedDiagram).toBe(source)
|
|
60
|
+
expect(result.fixesApplied).toEqual([])
|
|
61
|
+
expect(result.remainingIssues).toEqual([
|
|
62
|
+
expect.objectContaining({
|
|
63
|
+
code: 'UNMATCHED_DEACTIVATION',
|
|
64
|
+
actorId: 'A',
|
|
65
|
+
}),
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('fixes the dangling half of a mixed-issue diagram, leaving the unmatched half reported', () => {
|
|
70
|
+
const source =
|
|
71
|
+
'sequenceDiagram\n activate A\n A->>B: hello\n deactivate B'
|
|
72
|
+
const result = fixActivationBalance(source)
|
|
73
|
+
expect(result.ok).toBe(false)
|
|
74
|
+
expect(result.fixesApplied).toEqual([
|
|
75
|
+
expect.stringContaining('Appended "deactivate A"'),
|
|
76
|
+
])
|
|
77
|
+
expect(result.fixedDiagram).toContain(' deactivate A')
|
|
78
|
+
expect(result.remainingIssues).toEqual([
|
|
79
|
+
expect.objectContaining({
|
|
80
|
+
code: 'UNMATCHED_DEACTIVATION',
|
|
81
|
+
actorId: 'B',
|
|
82
|
+
}),
|
|
83
|
+
])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('throws a clear error for a non-sequence diagram', () => {
|
|
87
|
+
expect(() => fixActivationBalance('graph LR\n A --> B')).toThrow(
|
|
88
|
+
/only supports sequence diagrams/,
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -6,16 +6,13 @@
|
|
|
6
6
|
* to inspect Y coordinates, rather than checking SVG output.
|
|
7
7
|
*/
|
|
8
8
|
import { describe, it, expect } from 'vitest'
|
|
9
|
-
import { parseSequenceDiagram } from '
|
|
10
|
-
import { layoutSequenceDiagram } from '
|
|
9
|
+
import { parseSequenceDiagram } from '@zombie-mermaid/mermaid-parser'
|
|
10
|
+
import { layoutSequenceDiagram } from '@zombie-mermaid/svg-renderer'
|
|
11
|
+
import { splitStatements } from '@zombie-mermaid/core'
|
|
11
12
|
|
|
12
13
|
/** Helper: parse and layout a sequence diagram from source lines */
|
|
13
14
|
function layout(source: string) {
|
|
14
|
-
|
|
15
|
-
.split('\n')
|
|
16
|
-
.map((l) => l.trim())
|
|
17
|
-
.filter((l) => l.length > 0 && !l.startsWith('%%'))
|
|
18
|
-
return layoutSequenceDiagram(parseSequenceDiagram(lines))
|
|
15
|
+
return layoutSequenceDiagram(parseSequenceDiagram(splitStatements(source)))
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
describe('sequence layout – block spacing', () => {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntactic error-detection tests for the sequence diagram parser
|
|
3
|
+
* (issue #762).
|
|
4
|
+
*
|
|
5
|
+
* The 8 pre-existing `throw` sites in `parseSequenceDiagram` all guard
|
|
6
|
+
* *semantic* rules mirrored from mermaid.js (duplicate actor ids, box
|
|
7
|
+
* nesting, create/destroy message mismatch — see
|
|
8
|
+
* sequence-parser.test.ts/sequence-activation-check.test.ts for those). No
|
|
9
|
+
* *syntactic* error was ever raised: a malformed arrow like `->>>` silently
|
|
10
|
+
* absorbed the extra character into the actor name instead of being
|
|
11
|
+
* rejected (verified: `Alice->>>Bob: Hello` created an actor literally
|
|
12
|
+
* named `">Bob"`), an unmatched `end` was silently ignored, and an
|
|
13
|
+
* `alt`/`loop`/`box` left open at EOF was silently accepted as if closed.
|
|
14
|
+
* These tests assert the parser now throws an actionable error instead,
|
|
15
|
+
* while confirming valid syntax — including short arrows, activation
|
|
16
|
+
* shorthand, and actor names that legitimately start with "x" — is
|
|
17
|
+
* unaffected.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect } from 'vitest'
|
|
20
|
+
import { parseSequenceDiagram } from '@zombie-mermaid/mermaid-parser'
|
|
21
|
+
import { splitStatements } from '@zombie-mermaid/core'
|
|
22
|
+
|
|
23
|
+
function parse(text: string) {
|
|
24
|
+
return parseSequenceDiagram(splitStatements(text))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('parseSequenceDiagram – malformed arrows', () => {
|
|
28
|
+
it('throws on an extra ">" past the longest known long-arrow token', () => {
|
|
29
|
+
// The issue's own verified repro: silently created an actor named
|
|
30
|
+
// `">Bob"` before this pass.
|
|
31
|
+
expect(() =>
|
|
32
|
+
parse(`sequenceDiagram
|
|
33
|
+
Alice->>>Bob: Hello`),
|
|
34
|
+
).toThrow(/Malformed sequence-diagram arrow.*"->>>"/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('throws on an extra ")" past a short open-arrow token', () => {
|
|
38
|
+
expect(() =>
|
|
39
|
+
parse(`sequenceDiagram
|
|
40
|
+
Alice-))Bob: Hello`),
|
|
41
|
+
).toThrow(/Malformed sequence-diagram arrow.*"-\)\)"/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('throws on an extra ")" past a short dashed-open-arrow token', () => {
|
|
45
|
+
expect(() =>
|
|
46
|
+
parse(`sequenceDiagram
|
|
47
|
+
Alice--))Bob: Hello`),
|
|
48
|
+
).toThrow(/Malformed sequence-diagram arrow/)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('mentions the known-good arrow set in the message', () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
parse(`sequenceDiagram
|
|
54
|
+
Alice->>>Bob: Hello`),
|
|
55
|
+
).toThrow(/->, -->, ->>, -->>, -x, --x, -\), --\)/)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reports the 1-based line number', () => {
|
|
59
|
+
expect(() =>
|
|
60
|
+
parse(`sequenceDiagram
|
|
61
|
+
participant Alice
|
|
62
|
+
Alice->>>Bob: Hello`),
|
|
63
|
+
).toThrow(/^Line 3:/)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('parseSequenceDiagram – unmatched "end"', () => {
|
|
68
|
+
it('throws on an "end" with no open block or box', () => {
|
|
69
|
+
expect(() =>
|
|
70
|
+
parse(`sequenceDiagram
|
|
71
|
+
Alice->>Bob: Hi
|
|
72
|
+
end`),
|
|
73
|
+
).toThrow(/"end" does not match any open block/)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('parseSequenceDiagram – unclosed blocks/boxes at EOF', () => {
|
|
78
|
+
it('throws when a loop is never closed', () => {
|
|
79
|
+
expect(() =>
|
|
80
|
+
parse(`sequenceDiagram
|
|
81
|
+
loop Every 5s
|
|
82
|
+
Alice->>Bob: Hi`),
|
|
83
|
+
).toThrow(/unclosed "loop" block/)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('throws when an alt is never closed', () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
parse(`sequenceDiagram
|
|
89
|
+
alt Happy path
|
|
90
|
+
Alice->>Bob: Hi`),
|
|
91
|
+
).toThrow(/unclosed "alt" block/)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('throws when a box is never closed', () => {
|
|
95
|
+
expect(() =>
|
|
96
|
+
parse(`sequenceDiagram
|
|
97
|
+
box Group1
|
|
98
|
+
participant Alice
|
|
99
|
+
Alice->>Alice: Hi`),
|
|
100
|
+
).toThrow(/unclosed "box Group1"/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('reports the line the block opened on', () => {
|
|
104
|
+
expect(() =>
|
|
105
|
+
parse(`sequenceDiagram
|
|
106
|
+
Alice->>Bob: Hi
|
|
107
|
+
loop Every 5s
|
|
108
|
+
Bob->>Alice: Pong`),
|
|
109
|
+
).toThrow(/^Line 3:/)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('parseSequenceDiagram – valid syntax is unaffected', () => {
|
|
114
|
+
it('still parses correct long arrows without throwing', () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
parse(`sequenceDiagram
|
|
117
|
+
Alice->>Bob: Hi
|
|
118
|
+
Bob-->>Alice: Hi back`),
|
|
119
|
+
).not.toThrow()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('still parses activation shorthand (+/-) without throwing', () => {
|
|
123
|
+
const d = parse(`sequenceDiagram
|
|
124
|
+
Alice->>+Bob: Hi
|
|
125
|
+
Bob-->>-Alice: Hi back`)
|
|
126
|
+
expect(d.messages[0]!.activate).toBe(true)
|
|
127
|
+
expect(d.messages[1]!.deactivate).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('still parses short open/cross arrows without throwing', () => {
|
|
131
|
+
const d = parse(`sequenceDiagram
|
|
132
|
+
Alice-)Bob: fire and forget
|
|
133
|
+
Bob--xAlice: lost message`)
|
|
134
|
+
expect(d.messages).toHaveLength(2)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('still parses bidirectional arrows without throwing', () => {
|
|
138
|
+
expect(() =>
|
|
139
|
+
parse(`sequenceDiagram
|
|
140
|
+
Alice<<->>Bob: sync
|
|
141
|
+
Alice<<-->>Bob: async sync`),
|
|
142
|
+
).not.toThrow()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('does not throw for an actor name that legitimately starts with "x" after a -x arrow', () => {
|
|
146
|
+
const d = parse(`sequenceDiagram
|
|
147
|
+
Alice-xXavier: lost`)
|
|
148
|
+
expect(d.actors.map((a) => a.id)).toContain('Xavier')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('still parses a properly closed loop/alt/box without throwing', () => {
|
|
152
|
+
expect(() =>
|
|
153
|
+
parse(`sequenceDiagram
|
|
154
|
+
box Group1
|
|
155
|
+
participant Alice
|
|
156
|
+
participant Bob
|
|
157
|
+
end
|
|
158
|
+
loop Every 5s
|
|
159
|
+
alt Happy path
|
|
160
|
+
Alice->>Bob: Hi
|
|
161
|
+
else Sad path
|
|
162
|
+
Alice-xBob: nope
|
|
163
|
+
end
|
|
164
|
+
end`),
|
|
165
|
+
).not.toThrow()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('still closes an "end" that matches a block nested inside a box', () => {
|
|
169
|
+
const d = parse(`sequenceDiagram
|
|
170
|
+
box Group1
|
|
171
|
+
participant Alice
|
|
172
|
+
loop Every 5s
|
|
173
|
+
Alice->>Alice: ping
|
|
174
|
+
end
|
|
175
|
+
end`)
|
|
176
|
+
expect(d.blocks).toHaveLength(1)
|
|
177
|
+
expect(d.boxes).toHaveLength(1)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -9,15 +9,12 @@ import {
|
|
|
9
9
|
parseSequenceDiagram,
|
|
10
10
|
isBlockType,
|
|
11
11
|
toBlockType,
|
|
12
|
-
} from '
|
|
12
|
+
} from '@zombie-mermaid/mermaid-parser'
|
|
13
|
+
import { splitStatements } from '@zombie-mermaid/core'
|
|
13
14
|
|
|
14
15
|
/** Helper to parse — preprocesses text the same way index.ts does */
|
|
15
16
|
function parse(text: string) {
|
|
16
|
-
|
|
17
|
-
.split('\n')
|
|
18
|
-
.map((l) => l.trim())
|
|
19
|
-
.filter((l) => l.length > 0 && !l.startsWith('%%'))
|
|
20
|
-
return parseSequenceDiagram(lines)
|
|
17
|
+
return parseSequenceDiagram(splitStatements(text))
|
|
21
18
|
}
|
|
22
19
|
|
|
23
20
|
// ============================================================================
|
|
@@ -15,13 +15,18 @@ import { describe, it, expect } from 'vitest'
|
|
|
15
15
|
import { splitStatements, detectDiagramType } from '@zombie-mermaid/core'
|
|
16
16
|
import { renderMermaidASCII, renderMermaidSVG } from '../index.ts'
|
|
17
17
|
|
|
18
|
+
/** Project a `splitStatements` result down to just the text, for assertions
|
|
19
|
+
* that don't care about position (see the dedicated `line` tests below for
|
|
20
|
+
* position coverage). */
|
|
21
|
+
const texts = (source: string) => splitStatements(source).map((s) => s.text)
|
|
22
|
+
|
|
18
23
|
describe('splitStatements (issue #181)', () => {
|
|
19
24
|
it('splits on newlines', () => {
|
|
20
|
-
expect(
|
|
25
|
+
expect(texts('graph TD\nA-->B')).toEqual(['graph TD', 'A-->B'])
|
|
21
26
|
})
|
|
22
27
|
|
|
23
28
|
it('splits on semicolons', () => {
|
|
24
|
-
expect(
|
|
29
|
+
expect(texts('graph TD;A-->B;B-->C')).toEqual([
|
|
25
30
|
'graph TD',
|
|
26
31
|
'A-->B',
|
|
27
32
|
'B-->C',
|
|
@@ -29,59 +34,53 @@ describe('splitStatements (issue #181)', () => {
|
|
|
29
34
|
})
|
|
30
35
|
|
|
31
36
|
it('drops empty statements from trailing or repeated separators', () => {
|
|
32
|
-
expect(
|
|
33
|
-
expect(
|
|
34
|
-
expect(
|
|
35
|
-
'graph TD',
|
|
36
|
-
'A-->B',
|
|
37
|
-
])
|
|
37
|
+
expect(texts('graph TD;A-->B;')).toEqual(['graph TD', 'A-->B'])
|
|
38
|
+
expect(texts('graph TD;;;A-->B')).toEqual(['graph TD', 'A-->B'])
|
|
39
|
+
expect(texts('graph TD\n\n\nA-->B')).toEqual(['graph TD', 'A-->B'])
|
|
38
40
|
})
|
|
39
41
|
|
|
40
42
|
it('trims whitespace around each statement', () => {
|
|
41
|
-
expect(
|
|
42
|
-
'graph TD',
|
|
43
|
-
'A-->B',
|
|
44
|
-
])
|
|
43
|
+
expect(texts(' graph TD ; A-->B ')).toEqual(['graph TD', 'A-->B'])
|
|
45
44
|
})
|
|
46
45
|
|
|
47
46
|
describe('does not split a semicolon that is not a separator', () => {
|
|
48
47
|
it('inside a double-quoted label', () => {
|
|
49
|
-
expect(
|
|
48
|
+
expect(texts('graph TD\nA["a; b"]-->B')).toEqual([
|
|
50
49
|
'graph TD',
|
|
51
50
|
'A["a; b"]-->B',
|
|
52
51
|
])
|
|
53
52
|
})
|
|
54
53
|
|
|
55
54
|
it('inside a single-quoted label', () => {
|
|
56
|
-
expect(
|
|
55
|
+
expect(texts("graph TD\nA['x; y']-->B")).toEqual([
|
|
57
56
|
'graph TD',
|
|
58
57
|
"A['x; y']-->B",
|
|
59
58
|
])
|
|
60
59
|
})
|
|
61
60
|
|
|
62
61
|
it('terminating a named character reference', () => {
|
|
63
|
-
expect(
|
|
62
|
+
expect(texts('graph TD\nA[&]-->B')).toEqual([
|
|
64
63
|
'graph TD',
|
|
65
64
|
'A[&]-->B',
|
|
66
65
|
])
|
|
67
66
|
})
|
|
68
67
|
|
|
69
68
|
it('terminating a numeric character reference', () => {
|
|
70
|
-
expect(
|
|
69
|
+
expect(texts('graph TD\nA[#]-->B')).toEqual([
|
|
71
70
|
'graph TD',
|
|
72
71
|
'A[#]-->B',
|
|
73
72
|
])
|
|
74
73
|
})
|
|
75
74
|
|
|
76
75
|
it('terminating a hex character reference', () => {
|
|
77
|
-
expect(
|
|
76
|
+
expect(texts('graph TD\nA[😀]-->B')).toEqual([
|
|
78
77
|
'graph TD',
|
|
79
78
|
'A[😀]-->B',
|
|
80
79
|
])
|
|
81
80
|
})
|
|
82
81
|
|
|
83
82
|
it('but still splits a real separator on the same line as an entity', () => {
|
|
84
|
-
expect(
|
|
83
|
+
expect(texts('graph TD;A[&]-->B;B-->C')).toEqual([
|
|
85
84
|
'graph TD',
|
|
86
85
|
'A[&]-->B',
|
|
87
86
|
'B-->C',
|
|
@@ -90,26 +89,20 @@ describe('splitStatements (issue #181)', () => {
|
|
|
90
89
|
|
|
91
90
|
it('and treats a lone ampersand as an ordinary character', () => {
|
|
92
91
|
// `A & B` is flowchart's parallel-link syntax, not an entity.
|
|
93
|
-
expect(
|
|
94
|
-
'graph TD',
|
|
95
|
-
'A-->B & C',
|
|
96
|
-
])
|
|
92
|
+
expect(texts('graph TD;A-->B & C')).toEqual(['graph TD', 'A-->B & C'])
|
|
97
93
|
})
|
|
98
94
|
})
|
|
99
95
|
|
|
100
96
|
describe('comments', () => {
|
|
101
97
|
it('drops whole-line comments without splitting their semicolons', () => {
|
|
102
|
-
expect(
|
|
98
|
+
expect(texts('graph TD\n%% note; not code\nA-->B')).toEqual([
|
|
103
99
|
'graph TD',
|
|
104
100
|
'A-->B',
|
|
105
101
|
])
|
|
106
102
|
})
|
|
107
103
|
|
|
108
104
|
it('drops a comment that only begins after a semicolon', () => {
|
|
109
|
-
expect(
|
|
110
|
-
'graph TD',
|
|
111
|
-
'A-->B',
|
|
112
|
-
])
|
|
105
|
+
expect(texts('graph TD\nA-->B; %% note')).toEqual(['graph TD', 'A-->B'])
|
|
113
106
|
})
|
|
114
107
|
|
|
115
108
|
it('lets the comment swallow the rest of the line', () => {
|
|
@@ -118,14 +111,14 @@ describe('splitStatements (issue #181)', () => {
|
|
|
118
111
|
* resurrect what follows. Splitting first and then discarding fragments
|
|
119
112
|
* that merely begin with `%%` left `C-->D` parsing as code.
|
|
120
113
|
*/
|
|
121
|
-
expect(
|
|
114
|
+
expect(texts('graph TD\nA-->B; %% note; C-->D')).toEqual([
|
|
122
115
|
'graph TD',
|
|
123
116
|
'A-->B',
|
|
124
117
|
])
|
|
125
118
|
})
|
|
126
119
|
|
|
127
120
|
it('treats %% inside a quoted label as text, not a comment', () => {
|
|
128
|
-
expect(
|
|
121
|
+
expect(texts('graph TD\nA["100%% done"]-->B')).toEqual([
|
|
129
122
|
'graph TD',
|
|
130
123
|
'A["100%% done"]-->B',
|
|
131
124
|
])
|
|
@@ -144,14 +137,11 @@ describe('splitStatements (issue #181)', () => {
|
|
|
144
137
|
['A[heart #9829; x]-->B', 'a symbol reference'],
|
|
145
138
|
['A[say #quot;hi#quot;]-->B', 'a named reference'],
|
|
146
139
|
])('keeps %s intact (%s)', (statement) => {
|
|
147
|
-
expect(
|
|
148
|
-
'graph TD',
|
|
149
|
-
statement,
|
|
150
|
-
])
|
|
140
|
+
expect(texts(`graph TD\n ${statement}`)).toEqual(['graph TD', statement])
|
|
151
141
|
})
|
|
152
142
|
|
|
153
143
|
it('still splits a real separator on the same line as one', () => {
|
|
154
|
-
expect(
|
|
144
|
+
expect(texts('graph TD;A[#59;]-->B;B-->C')).toEqual([
|
|
155
145
|
'graph TD',
|
|
156
146
|
'A[#59;]-->B',
|
|
157
147
|
'B-->C',
|
|
@@ -159,7 +149,7 @@ describe('splitStatements (issue #181)', () => {
|
|
|
159
149
|
})
|
|
160
150
|
|
|
161
151
|
it('leaves a bare # that is not a reference alone', () => {
|
|
162
|
-
expect(
|
|
152
|
+
expect(texts('graph TD;A[C# lang]-->B')).toEqual([
|
|
163
153
|
'graph TD',
|
|
164
154
|
'A[C# lang]-->B',
|
|
165
155
|
])
|
|
@@ -167,6 +157,34 @@ describe('splitStatements (issue #181)', () => {
|
|
|
167
157
|
})
|
|
168
158
|
})
|
|
169
159
|
|
|
160
|
+
describe('splitStatements — line numbers (issue #760)', () => {
|
|
161
|
+
it('reports the 1-based physical source line for each newline-separated statement', () => {
|
|
162
|
+
const stmts = splitStatements('graph TD\nA-->B\nB-->C')
|
|
163
|
+
expect(stmts.map((s) => s.line)).toEqual([1, 2, 3])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('carries forward the original line number across dropped blank lines', () => {
|
|
167
|
+
const stmts = splitStatements('graph TD\n\n\nA-->B')
|
|
168
|
+
expect(stmts.map((s) => ({ text: s.text, line: s.line }))).toEqual([
|
|
169
|
+
{ text: 'graph TD', line: 1 },
|
|
170
|
+
{ text: 'A-->B', line: 4 },
|
|
171
|
+
])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('carries forward the original line number across a dropped comment line', () => {
|
|
175
|
+
const stmts = splitStatements('graph TD\n%% a comment\nA-->B')
|
|
176
|
+
expect(stmts.map((s) => ({ text: s.text, line: s.line }))).toEqual([
|
|
177
|
+
{ text: 'graph TD', line: 1 },
|
|
178
|
+
{ text: 'A-->B', line: 3 },
|
|
179
|
+
])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('gives every semicolon-separated statement on one physical line the same line number', () => {
|
|
183
|
+
const stmts = splitStatements('graph TD\nA-->B;B-->C;C-->D')
|
|
184
|
+
expect(stmts.map((s) => s.line)).toEqual([1, 2, 2, 2])
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
170
188
|
describe('semicolon-separated diagrams render their bodies (issue #181)', () => {
|
|
171
189
|
const cases = [
|
|
172
190
|
{
|
|
@@ -16,27 +16,27 @@ B --> Y
|
|
|
16
16
|
|
|
|
17
17
|
|
|
|
18
18
|
|
|
|
19
|
-
|
|
20
|
-
|LR
|
|
21
|
-
| |
|
|
22
|
-
| v
|
|
23
|
-
| +---+
|
|
24
|
-
| | |
|
|
25
|
-
| | A |
|
|
26
|
-
| | |
|
|
27
|
-
| +-+-+
|
|
28
|
-
| |
|
|
29
|
-
| |
|
|
30
|
-
| |
|
|
31
|
-
| |
|
|
32
|
-
| v
|
|
33
|
-
| +---+
|
|
34
|
-
| | |
|
|
35
|
-
| | B |
|
|
36
|
-
| | |
|
|
37
|
-
| +-+-+
|
|
38
|
-
| |
|
|
39
|
-
|
|
19
|
+
+---|----+
|
|
20
|
+
|LR Group|
|
|
21
|
+
| | |
|
|
22
|
+
| v |
|
|
23
|
+
| +---+ |
|
|
24
|
+
| | | |
|
|
25
|
+
| | A | |
|
|
26
|
+
| | | |
|
|
27
|
+
| +-+-+ |
|
|
28
|
+
| | |
|
|
29
|
+
| | |
|
|
30
|
+
| | |
|
|
31
|
+
| | |
|
|
32
|
+
| v |
|
|
33
|
+
| +---+ |
|
|
34
|
+
| | | |
|
|
35
|
+
| | B | |
|
|
36
|
+
| | | |
|
|
37
|
+
| +-+-+ |
|
|
38
|
+
| | |
|
|
39
|
+
+---|----+
|
|
40
40
|
|
|
|
41
41
|
|
|
|
42
42
|
v
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards the "legible, readable contrast" acceptance criterion the #684
|
|
3
|
+
* theme-selector restoration's 16 per-theme verification sub-issues
|
|
4
|
+
* (#691-706) each individually ask for, systematically across every
|
|
5
|
+
* built-in theme at once rather than eyeballing 16 screenshots one at a
|
|
6
|
+
* time — see docs/decisions/theme-selector-shared-state.md and this
|
|
7
|
+
* repo's CLAUDE.md's "favor tooling over manual scanning" convention.
|
|
8
|
+
*
|
|
9
|
+
* Computes the actual WCAG 2.x contrast ratio (relative luminance, per
|
|
10
|
+
* https://www.w3.org/TR/WCAG21/#dfn-relative-luminance) between each
|
|
11
|
+
* theme's `bg`/`fg` pair — not a hand-picked pass/fail per theme, so a
|
|
12
|
+
* future THEMES addition with a genuinely illegible pair fails this test
|
|
13
|
+
* automatically instead of only being caught by a human noticing a
|
|
14
|
+
* screenshot looks wrong.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import { THEMES } from '@zombie-mermaid/core'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The threshold every theme must clear. WCAG AA's own thresholds are 4.5:1
|
|
21
|
+
* for normal text and 3:1 for large/bold text; diagram node labels here
|
|
22
|
+
* are typically medium, semi-bold text, closer to the "large text"
|
|
23
|
+
* category than dense body copy. 4.0 sits between the two -- strict enough
|
|
24
|
+
* to catch a genuinely illegible pair, loose enough not to fail
|
|
25
|
+
* `solarized-light` (4.13:1), whose muted bg/fg pair matches Ethan
|
|
26
|
+
* Schoonover's actual published Solarized values rather than being a
|
|
27
|
+
* mistake in this repo's THEMES data -- #684's own non-goals rule out
|
|
28
|
+
* changing THEMES' colors to "fix" a curated editor-theme's real values.
|
|
29
|
+
*/
|
|
30
|
+
const MIN_CONTRAST_RATIO = 4.0
|
|
31
|
+
|
|
32
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
33
|
+
const clean = hex.replace('#', '')
|
|
34
|
+
return [
|
|
35
|
+
parseInt(clean.slice(0, 2), 16),
|
|
36
|
+
parseInt(clean.slice(2, 4), 16),
|
|
37
|
+
parseInt(clean.slice(4, 6), 16),
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** sRGB channel -> linear-light value, the WCAG relative-luminance step. */
|
|
42
|
+
function linearize(channel: number): number {
|
|
43
|
+
const c = channel / 255
|
|
44
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function relativeLuminance(hex: string): number {
|
|
48
|
+
const [r, g, b] = hexToRgb(hex)
|
|
49
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** WCAG contrast ratio between two colors, always >= 1 (order-independent). */
|
|
53
|
+
function contrastRatio(hexA: string, hexB: string): number {
|
|
54
|
+
const lumA = relativeLuminance(hexA)
|
|
55
|
+
const lumB = relativeLuminance(hexB)
|
|
56
|
+
const lighter = Math.max(lumA, lumB)
|
|
57
|
+
const darker = Math.min(lumA, lumB)
|
|
58
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('every built-in theme has a legible bg/fg contrast ratio', () => {
|
|
62
|
+
for (const [themeKey, colors] of Object.entries(THEMES)) {
|
|
63
|
+
it(`${themeKey}: bg/fg contrast is at least ${MIN_CONTRAST_RATIO}:1`, () => {
|
|
64
|
+
const ratio = contrastRatio(colors.bg, colors.fg)
|
|
65
|
+
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST_RATIO)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('contrastRatio() itself', () => {
|
|
71
|
+
it('returns 1 for identical colors', () => {
|
|
72
|
+
expect(contrastRatio('#808080', '#808080')).toBeCloseTo(1, 5)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns 21 for pure black vs pure white (the WCAG maximum)', () => {
|
|
76
|
+
expect(contrastRatio('#000000', '#FFFFFF')).toBeCloseTo(21, 1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('is symmetric regardless of argument order', () => {
|
|
80
|
+
const a = contrastRatio('#1a1b26', '#a9b1d6')
|
|
81
|
+
const b = contrastRatio('#a9b1d6', '#1a1b26')
|
|
82
|
+
expect(a).toBeCloseTo(b, 10)
|
|
83
|
+
})
|
|
84
|
+
})
|