Files
scriptorium/internal/diagram/diagram_test.go
T

164 lines
6.5 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package diagram
import (
"bytes"
"encoding/xml"
"os"
"path/filepath"
"testing"
)
// goldenCorpus is the project's own hand-written corpus. Every input has
// its rendered SVG in testdata, byte for byte.
var goldenCorpus = []struct {
name string
input string
}{
{"fc-basic", "flowchart TD\n A[Start] --> B[End]\n"},
{"fc-lr", "flowchart LR\n A --> B --> C\n"},
{"fc-shapes", "flowchart TD\n A[Rect]\n B(Round)\n C{Diamond}\n D((Circle))\n E[[Subroutine]]\n F>Asymmetric]\n G([Stadium])\n H{{Hexagon}}\n"},
{"fc-edge-kinds", "flowchart TD\n A --- B\n B --> C\n C -.- D\n D -.-> E\n E ==> F\n F ---> G\n"},
{"fc-edge-labels", "flowchart TD\n A -- text --> B\n A -->|piped| C\n A -. dotted text .-> D\n A == thick text ==> E\n"},
{"fc-branching", "flowchart TD\n A --> B & C\n D & E --> F\n B --> F\n"},
{"fc-quoted-labels", `flowchart TD
A["Label with (brackets) and \"quotes\""] --> B['single']
`},
{"fc-subgraph", "flowchart TD\n A --> B\n subgraph Group One\n B --> C\n end\n C --> D\n"},
{"fc-subgraph-direction", "flowchart TB\n A --> B\n subgraph Inner\n direction LR\n B --> C\n B --> D\n end\n"},
{"fc-classes", "flowchart TD\n A[First]:::urgent --> B[Second]\n classDef urgent fill:#f96,stroke:#333,stroke-width:2px\n class A,B urgent\n style B fill:#9f9\n linkStyle 0 stroke:#f39\n"},
{"fc-graph-alias", "graph LR\n A[Alpha] --> B[Beta]\n"},
{"fc-cycle", "flowchart TD\n A --> B --> C --> A\n C --> D\n"},
{"fc-single", "flowchart TD\n Lonely\n"},
{"fc-multiline", "flowchart TD\n A[\"first line<br/>second line\"] --> B\n"},
{"fc-direction-line", "flowchart TD\n A --> B\n direction LR\n"},
{"sq-basic", "sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi\n"},
{"sq-arrows", "sequenceDiagram\n A->>B: solid arrow\n B-->>A: dashed arrow\n A-xB: solid cross\n B--xA: dashed cross\n A->B: solid open\n B-->A: dashed open\n"},
{"sq-participants", "sequenceDiagram\n participant A as Alice\n actor B as Bob\n participant C\n A->>B: hi\n B->>C: forward\n"},
{"sq-notes", "sequenceDiagram\n participant A\n participant B\n Note left of A: on the left\n Note right of B: on the right\n Note over A: alone\n Note over A,B: spanning\n"},
{"sq-alt", "sequenceDiagram\n A->>B: check\n alt yes\n A->>B: proceed\n else no\n B-->>A: refuse\n end\n"},
{"sq-loop-opt", "sequenceDiagram\n loop each round\n A->>B: tick\n end\n opt maybe\n B-->>A: tock\n end\n"},
{"sq-par", "sequenceDiagram\n par left\n A->>B: one\n and right\n C->>D: two\n end\n"},
{"sq-critical", "sequenceDiagram\n critical locked\n A->>B: work\n option unlocked\n B-->>A: skip\n end\n"},
{"sq-break", "sequenceDiagram\n break failure\n A->>B: stop\n end\n"},
{"sq-rect", "sequenceDiagram\n rect rgb(200, 255, 200)\n A->>B: inside\n end\n rect #e0e0ff\n B-->>A: also inside\n end\n"},
{"sq-autonumber", "sequenceDiagram autonumber\n A->>B: first\n B-->>A: second\n A->>A: self\n"},
{"sq-divider", "sequenceDiagram\n A->>B: before\n ... section break ...\n A->>B: after\n"},
{"sq-activation", "sequenceDiagram\n activate A\n A->>+B: request\n B-->>-A: response\n deactivate A\n"},
{"sq-self", "sequenceDiagram\n A->>A: think\n A-->A: rethink\n"},
{"sq-nested", "sequenceDiagram\n alt outer\n loop inner\n A->>B: x\n end\n else other\n B->>A: y\n end\n"},
}
func TestGolden(t *testing.T) {
for _, tc := range goldenCorpus {
t.Run(tc.name, func(t *testing.T) {
got, err := Render([]byte(tc.input))
if err != nil {
t.Fatalf("render: %v", err)
}
golden := filepath.Join("testdata", tc.name+".svg")
if os.Getenv("SCRIPTORIUM_GOLDEN_UPDATE") == "1" {
if err := os.WriteFile(golden, got, 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("read golden: %v (run with SCRIPTORIUM_GOLDEN_UPDATE=1 once)", err)
}
if !bytes.Equal(got, want) {
t.Errorf("output changed; run with SCRIPTORIUM_GOLDEN_UPDATE=1 after review\ngot: %s\nwant: %s", got, want)
}
})
}
}
func TestWellFormed(t *testing.T) {
for _, tc := range goldenCorpus {
out, err := Render([]byte(tc.input))
if err != nil {
t.Fatalf("%s: render: %v", tc.name, err)
}
dec := xml.NewDecoder(bytes.NewReader(out))
for {
_, err := dec.Token()
if err != nil {
if err.Error() == "EOF" {
break
}
t.Errorf("%s: malformed XML: %v\n%s", tc.name, err, out)
break
}
}
}
}
func TestDeterministic(t *testing.T) {
for _, tc := range goldenCorpus {
first, err := Render([]byte(tc.input))
if err != nil {
t.Fatalf("%s: render: %v", tc.name, err)
}
for range 5 {
next, err := Render([]byte(tc.input))
if err != nil || !bytes.Equal(first, next) {
t.Fatalf("%s: output differs between calls", tc.name)
}
}
}
}
func TestRefusesOtherTypes(t *testing.T) {
for _, src := range []string{
"pie\n \"a\": 50\n",
"gantt\n title x\n",
"classDiagram\n A <|-- B\n",
"stateDiagram-v2\n [*] --> A\n",
"erDiagram\n",
} {
_, err := Render([]byte(src))
if err == nil {
t.Errorf("expected refusal for %q", src)
}
}
}
func TestParseErrors(t *testing.T) {
cases := []struct {
src string
want string
}{
{"flowchart TD\n A -->\n", "flowchart line 2"},
{"flowchart TD\n subgraph S\n A --> B\n", "has no end"},
{"flowchart TD\n end\n", "end without subgraph"},
{"flowchart TD\n click A callback\n", "click is unsupported"},
{"flowchart SIDEWAYS\n A --> B\n", "unknown flow direction"},
{"flowchart TD\n A -- B\n", "unfinished edge token"},
{"sequenceDiagram\n A->B no colon\n", "message needs a colon"},
{"sequenceDiagram\n nonsense line\n", "cannot parse"},
{"sequenceDiagram\n end\n", "end without a block"},
{"sequenceDiagram\n alt x\n A->>B: y\n", "has no end"},
{"sequenceDiagram\n rect blue\n A->>B: y\n end\n", "rect needs"},
{"sequenceDiagram\n note behind A: x\n", "note needs left of"},
}
for _, tc := range cases {
_, err := Render([]byte(tc.src))
if err == nil {
t.Errorf("input %q: expected error", tc.src)
continue
}
if !bytes.Contains([]byte(err.Error()), []byte(tc.want)) {
t.Errorf("input %q: error %q does not mention %q", tc.src, err, tc.want)
}
}
}
func TestEmptySource(t *testing.T) {
if _, err := Render([]byte("%% only a comment\n\n")); err == nil {
t.Error("expected an error for an empty diagram")
}
}