// Copyright (c) 2026 Petr BalvĂ­n (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
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") } }