import { describe, expect, it } from "vitest"
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import type { SequenceNode } from "@/lib/diagram-engine/types"
import { findNode } from "@/lib/diagram-engine/types"
import {
absoluteRects,
escapesParent,
outsidePage,
rectOf,
} from "./fixtures/geometry"
const LOGIN: Operation[] = [
{ op: "add_sequence", id: "s", label: "Login flow" },
{ op: "add_box", id: "u", parent: "s", label: "User" },
{ op: "add_box", id: "web", parent: "s", label: "Web App" },
{ op: "add_box", id: "auth", parent: "s", label: "Auth Service" },
{ op: "add_box", id: "db", parent: "s", label: "Database" },
{ op: "link", source: "u", target: "web", label: "credentials", step: 1 },
{
op: "link",
source: "web",
target: "auth",
label: "POST /login",
step: 2,
},
{ op: "link", source: "auth", target: "db", label: "find user", step: 3 },
{ op: "link", source: "db", target: "auth", label: "user record", step: 4 },
{
op: "link",
source: "auth",
target: "auth",
label: "sign token",
step: 5,
},
{ op: "link", source: "auth", target: "web", label: "JWT", step: 6 },
{ op: "link", source: "web", target: "u", label: "redirect", step: 7 },
]
const PARTICIPANTS = ["u", "web", "auth", "db"]
/** Each message's y, in the order the cells were written. */
function messageYs(xml: string): { id: string; from: number; to: number }[] {
return [
...xml.matchAll(
//g,
),
].map((m) => ({ id: m[1], from: Number(m[2]), to: Number(m[3]) }))
}
describe("sequence diagram: layout", () => {
const result = restructureDiagram("", LOGIN)
const xml = result.xml as string
const rects = absoluteRects(xml)
it("builds without errors", () => {
expect(result.errors).toEqual([])
})
it("puts the participants in a row, in declaration order", () => {
const xs = PARTICIPANTS.map((id) => rectOf(rects, id).x)
expect(xs).toEqual([...xs].sort((a, b) => a - b))
// All the heads share a top edge.
const ys = PARTICIPANTS.map((id) => rectOf(rects, id).y)
expect(new Set(ys).size).toBe(1)
})
it("draws each participant as a lifeline, head and line in one cell", () => {
// One cell so draw.io keeps them together when the user drags the participant.
for (const id of PARTICIPANTS)
expect(xml).toMatch(
new RegExp(`id="${id}"[^>]*shape=umlLifeline[^>]*size=\\d+`),
)
})
it("makes the lifelines long enough for every message", () => {
const lowest = Math.max(...messageYs(xml).map((m) => m.to))
for (const id of PARTICIPANTS) {
const r = rects.get(id) as { y: number; h: number }
expect(r.y + r.h).toBeGreaterThan(lowest)
}
})
it("orders the messages down the page by step number", () => {
const ys = messageYs(xml)
expect(ys.length).toBe(7)
const tops = ys.map((m) => Math.min(m.from, m.to))
expect(tops).toEqual([...tops].sort((a, b) => a - b))
// No two messages on the same line.
expect(new Set(tops).size).toBe(7)
})
it("draws a message as a horizontal line between two lifelines", () => {
const ys = messageYs(xml)
// Every message except the self-call is level.
const level = ys.filter((m) => m.from === m.to)
expect(level.length).toBe(6)
})
it("steps a self-message out and back a row lower", () => {
const self = messageYs(xml).find((m) => m.from !== m.to)
expect(self).toBeDefined()
expect((self as { to: number }).to).toBeGreaterThan(
(self as { from: number }).from,
)
// Two waypoints take it out to the side and back.
expect(xml).toMatch(
/id="ed5"[\s\S]*?]*\/>]*\/><\/Array>/,
)
})
it("keeps everything inside the page and inside its parent", () => {
expect(outsidePage(xml, ["__title"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
})
it("numbers unnumbered messages in declaration order", () => {
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s", label: "" },
{ op: "add_box", id: "a", parent: "s", label: "A" },
{ op: "add_box", id: "b", parent: "s", label: "B" },
{ op: "link", source: "a", target: "b", label: "first" },
{ op: "link", source: "b", target: "a", label: "second" },
])
expect(r.errors).toEqual([])
const ys = messageYs(r.xml as string)
expect(ys.length).toBe(2)
expect(ys[0].from).toBeLessThan(ys[1].from)
})
it("allows several messages between the same two participants", () => {
// A back-and-forth conversation is the norm here, so the duplicate-edge guard that
// protects other diagram kinds must not apply.
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s", label: "" },
{ op: "add_box", id: "a", parent: "s", label: "A" },
{ op: "add_box", id: "b", parent: "s", label: "B" },
{ op: "link", source: "a", target: "b", label: "ask", step: 1 },
{
op: "link",
source: "a",
target: "b",
label: "ask again",
step: 2,
},
])
expect(r.errors).toEqual([])
expect(messageYs(r.xml as string).length).toBe(2)
})
it("still rejects a duplicate edge outside a sequence diagram", () => {
const r = restructureDiagram("", [
{ op: "add_box", id: "a", label: "A" },
{ op: "add_box", id: "b", label: "B" },
{ op: "link", source: "a", target: "b" },
{ op: "link", source: "a", target: "b" },
])
expect(r.errors[0]).toContain("already exists")
})
it("removes one message by step, leaving the others", () => {
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s", label: "" },
{ op: "add_box", id: "a", parent: "s", label: "A" },
{ op: "add_box", id: "b", parent: "s", label: "B" },
{ op: "link", source: "a", target: "b", label: "one", step: 1 },
{ op: "link", source: "a", target: "b", label: "two", step: 2 },
{ op: "unlink", source: "a", target: "b", step: 1 },
])
expect(r.errors).toEqual([])
expect(r.outline).toContain("two")
expect(r.outline).not.toContain("one")
})
it("keeps two sequence diagrams on one page independent", () => {
// The fallback numbering has to restart per container, or the second diagram's
// messages continue the first one's count and fall below its own lifelines.
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s1", label: "First" },
{ op: "add_box", id: "a", parent: "s1", label: "A" },
{ op: "add_box", id: "b", parent: "s1", label: "B" },
{ op: "link", source: "a", target: "b" },
{ op: "add_sequence", id: "s2", label: "Second" },
{ op: "add_box", id: "c", parent: "s2", label: "C" },
{ op: "add_box", id: "d", parent: "s2", label: "D" },
{ op: "link", source: "c", target: "d" },
])
expect(r.errors).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
})
})
describe("sequence diagram: round-trip", () => {
it("comes back as a sequence with the same participants", () => {
const first = restructureDiagram("", LOGIN)
const { tree, warnings } = parseDiagram(first.xml as string)
expect(warnings).toEqual([])
const seq = findNode(tree, "s") as SequenceNode
expect(seq.kind).toBe("sequence")
expect(seq.children.map((c) => c.id)).toEqual(PARTICIPANTS)
expect(seq.label).toBe("Login flow")
})
it("keeps every message, including the repeat pair and the self-call", () => {
const first = restructureDiagram("", LOGIN)
const { tree } = parseDiagram(first.xml as string)
expect(tree.links.length).toBe(7)
expect(
tree.links.some((l) => l.source === "auth" && l.target === "auth"),
).toBe(true)
})
it("does not move anything on a re-layout", () => {
const first = restructureDiagram("", LOGIN)
const second = restructureDiagram(first.xml as string, [])
expect(second.errors).toEqual([])
expect(second.warnings).toEqual([])
const a = absoluteRects(first.xml as string)
const b = absoluteRects(second.xml as string)
for (const id of PARTICIPANTS) expect(b.get(id)).toEqual(a.get(id))
})
it("does not let the lifelines grow on every pass", () => {
// A lifeline's cell covers the head AND the line, so reading its full height back as
// the participant's own size would make it taller each time.
const first = restructureDiagram("", LOGIN)
const second = restructureDiagram(first.xml as string, [])
const third = restructureDiagram(second.xml as string, [])
const h = (xml: string) => rectOf(absoluteRects(xml), "u").h
expect(h(second.xml as string)).toBe(h(first.xml as string))
expect(h(third.xml as string)).toBe(h(first.xml as string))
})
it("adds a participant to an existing diagram without redrawing it", () => {
const first = restructureDiagram("", LOGIN)
const second = restructureDiagram(first.xml as string, [
{ op: "add_box", id: "cache", parent: "s", label: "Cache" },
{
op: "link",
source: "auth",
target: "cache",
label: "check",
step: 8,
},
])
expect(second.errors).toEqual([])
const rects = absoluteRects(second.xml as string)
expect(rects.has("cache")).toBe(true)
// The new participant joins the row rather than landing on top of another.
expect(rectOf(rects, "cache").x).toBeGreaterThan(rectOf(rects, "db").x)
expect(escapesParent(second.xml as string)).toEqual([])
})
})