diff --git a/.oxlintrc.json b/.oxlintrc.json index d57e5979..7aaf6173 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -280,10 +280,12 @@ { // Diagram-core's schema boundary files may use stable Effect Schema. "files": [ + "packages/diagram/core/src/diagram.ts", "packages/diagram/core/src/intermediate.ts", "packages/diagram/core/src/types/flowchart.ts", "packages/diagram/core/src/types/flowchart.test.ts", - "packages/diagram/core/src/types/mindmap.ts" + "packages/diagram/core/src/types/mindmap.ts", + "packages/diagram/core/src/types/sequence.ts" ], "rules": { "eslint/no-restricted-imports": [ diff --git a/apps/excalidraw/src/components/excalidraw-workspace/bound-labels.browser.test.tsx b/apps/excalidraw/src/components/excalidraw-workspace/bound-labels.browser.test.tsx index 1c96e854..8e4732a3 100644 --- a/apps/excalidraw/src/components/excalidraw-workspace/bound-labels.browser.test.tsx +++ b/apps/excalidraw/src/components/excalidraw-workspace/bound-labels.browser.test.tsx @@ -153,6 +153,7 @@ const agentCanvas: CanvasSpec = { }; const sequence = renderSequenceDiagram({ + type: "sequence", id: "long-participants", title: "Long participants", participants: [ diff --git a/packages/diagram/agent/src/lib/code-mode/effect-runtime.test.ts b/packages/diagram/agent/src/lib/code-mode/effect-runtime.test.ts index df3250a8..2dfa8f8f 100644 --- a/packages/diagram/agent/src/lib/code-mode/effect-runtime.test.ts +++ b/packages/diagram/agent/src/lib/code-mode/effect-runtime.test.ts @@ -316,6 +316,7 @@ layer(runtimeLayer)("Code Mode Effect workflow", (it) => { it.effect("preserves sequence stroke styles through scene encoding", () => Effect.gen(function* () { const rendered = renderSequenceDiagram({ + type: "sequence", id: "return-message", title: "Return message", participants: [ diff --git a/packages/diagram/agent/src/lib/code-mode/runtime.ts b/packages/diagram/agent/src/lib/code-mode/runtime.ts index 5bd2227c..39915b38 100644 --- a/packages/diagram/agent/src/lib/code-mode/runtime.ts +++ b/packages/diagram/agent/src/lib/code-mode/runtime.ts @@ -7,6 +7,8 @@ import { getCanvasValidationIssues, getFlowchartValidationIssues, parseMindmapDiagram, + parseSequenceDiagram, + sequenceLifelineId, validateFlowchartDiagram, type FlowchartDiagram, type FlowchartValidationIssueRef, @@ -23,7 +25,6 @@ import { renderIntermediateDiagram, renderSequenceDiagram, isStructurallyValidSequenceLifeline, - sequenceLifelineId, type RenderedDiagramScene, type ScenePoint, } from "@sketchi/diagram-renderer"; @@ -2272,7 +2273,7 @@ const buildSequenceDiagramWorkflow = Effect.fn("codeMode.buildSequenceDiagram.wo } const scene = yield* Effect.try({ - try: () => renderSequenceDiagram(normalizedSpec), + try: () => renderSequenceDiagram(parseSequenceDiagram({ ...normalizedSpec, type: "sequence" })), catch: (cause) => new BuildSequenceDiagramFailure({ status: "render_failed", diff --git a/packages/diagram/core/README.md b/packages/diagram/core/README.md index ca99f50b..d48e070e 100644 --- a/packages/diagram/core/README.md +++ b/packages/diagram/core/README.md @@ -7,10 +7,17 @@ flowchart LR Fixtures["fixtures"] --> Registry["diagram type registry"] Registry --> Flowchart["flowchart contract"] Registry --> Mindmap["mindmap contract"] + Registry --> Sequence["sequence contract"] Flowchart --> Consumers["generation, rendering, UI"] Mindmap --> Consumers + Sequence --> Consumers ``` +Flowchart and mindmap share the node/edge `IntermediateDiagram` graph. +Sequence diagrams keep their own `SequenceDiagram` contract: ordered +participants and chronologically ordered messages, never a graph. +`CanonicalDiagramSchema` and `parseCanonicalDiagram` accept any family. + | Owns | Does not own | | ------------------------------- | --------------------------- | | diagram type registry | model calls or prompts | diff --git a/packages/diagram/core/src/diagram.ts b/packages/diagram/core/src/diagram.ts new file mode 100644 index 00000000..8ef89c36 --- /dev/null +++ b/packages/diagram/core/src/diagram.ts @@ -0,0 +1,52 @@ +import { Schema } from "effect"; + +import { + type FlowchartDiagram, + FlowchartDiagramSchema, + validateFlowchartDiagram, +} from "./types/flowchart.js"; +import { + type MindmapDiagram, + MindmapDiagramSchema, + validateMindmapDiagram, +} from "./types/mindmap.js"; +import { + type SequenceDiagram, + SequenceDiagramSchema, + validateSequenceDiagram, +} from "./types/sequence.js"; +import type { DiagramTypeValue } from "./types.js"; + +/** One diagram of any canonical family, discriminated by `type`. */ +export type CanonicalDiagram = FlowchartDiagram | MindmapDiagram | SequenceDiagram; + +// A family added to DIAGRAM_TYPES without a contract here fails to compile. +const familySchemas = { + flowchart: FlowchartDiagramSchema, + mindmap: MindmapDiagramSchema, + sequence: SequenceDiagramSchema, +} satisfies Record; + +export const CanonicalDiagramSchema = Schema.Union([ + familySchemas.flowchart, + familySchemas.mindmap, + familySchemas.sequence, +]); + +/** Apply the family's semantic validation to an already decoded diagram. */ +export function validateCanonicalDiagram(diagram: CanonicalDiagram): CanonicalDiagram { + switch (diagram.type) { + case "flowchart": + return validateFlowchartDiagram(diagram); + case "mindmap": + return validateMindmapDiagram(diagram); + case "sequence": + return validateSequenceDiagram(diagram); + } +} + +export function parseCanonicalDiagram(input: unknown): CanonicalDiagram { + return validateCanonicalDiagram( + Schema.decodeUnknownSync(CanonicalDiagramSchema, { errors: "all" })(input), + ); +} diff --git a/packages/diagram/core/src/fixtures.ts b/packages/diagram/core/src/fixtures.ts index 37aeb4d4..7cccb098 100644 --- a/packages/diagram/core/src/fixtures.ts +++ b/packages/diagram/core/src/fixtures.ts @@ -1,8 +1,11 @@ import { flowchartFixture } from "./types/flowchart.js"; import { mindmapFixture } from "./types/mindmap.js"; -import { parseIntermediateDiagram } from "./intermediate.js"; +import { sequenceFixture } from "./types/sequence.js"; +import type { CanonicalDiagram } from "./diagram.js"; -export const diagramFixtures = [ - parseIntermediateDiagram(flowchartFixture), - parseIntermediateDiagram(mindmapFixture), +/** One maintained fixture per canonical family. */ +export const diagramFixtures: readonly CanonicalDiagram[] = [ + flowchartFixture, + mindmapFixture, + sequenceFixture, ]; diff --git a/packages/diagram/core/src/index.ts b/packages/diagram/core/src/index.ts index 93e73a94..de02b83c 100644 --- a/packages/diagram/core/src/index.ts +++ b/packages/diagram/core/src/index.ts @@ -3,8 +3,10 @@ export * from "./fixtures.js"; export * from "./icon.js"; export * from "./intermediate.js"; export * from "./canvas.js"; +export * from "./diagram.js"; export * from "./types/mindmap.js"; export * from "./types/flowchart.js"; +export * from "./types/sequence.js"; export * from "./segments.js"; export * from "./hash.js"; export * from "./text-metrics.js"; diff --git a/packages/diagram/core/src/intermediate.ts b/packages/diagram/core/src/intermediate.ts index 7279327b..133bf837 100644 --- a/packages/diagram/core/src/intermediate.ts +++ b/packages/diagram/core/src/intermediate.ts @@ -1,7 +1,7 @@ import { Effect, Schema } from "effect"; import { DIAGRAM_ICON_SLUG_MAX_LENGTH, DIAGRAM_ICON_SLUG_PATTERN } from "./icon.js"; -import { DIAGRAM_TYPES } from "./types.js"; +import { DIAGRAM_TYPES, GRAPH_DIAGRAM_TYPES } from "./types.js"; const NonEmptyString = Schema.NonEmptyString; const Metadata = Schema.Record(Schema.String, Schema.Unknown); @@ -27,10 +27,13 @@ function withDefault(schema: S, value: S["Encoded"]) { } export const DiagramTypeSchema = Schema.Literals(DIAGRAM_TYPES); +/** Sequence diagrams have their own contract; only graph families use nodes and edges. */ +export const GraphDiagramTypeSchema = Schema.Literals(GRAPH_DIAGRAM_TYPES); export const LayoutDirectionSchema = Schema.Literals(["TB", "BT", "LR", "RL"]); export const EdgeRoutingSchema = Schema.Literals(["straight", "orthogonal", "curved"]); export type DiagramType = typeof DiagramTypeSchema.Type; +export type GraphDiagramType = typeof GraphDiagramTypeSchema.Type; export type LayoutDirection = typeof LayoutDirectionSchema.Type; export type EdgeRouting = typeof EdgeRoutingSchema.Type; @@ -83,7 +86,7 @@ export const DiagramLayoutSchema = DiagramLayout; export class IntermediateDiagram extends Schema.Class("IntermediateDiagram")({ id: NonEmptyString, title: NonEmptyString, - type: withDefault(DiagramTypeSchema, "flowchart"), + type: withDefault(GraphDiagramTypeSchema, "flowchart"), nodes: Schema.Array(DiagramNode).pipe(Schema.mutable).check(Schema.isMinLength(1)), edges: withDefault(Schema.Array(DiagramEdge).pipe(Schema.mutable), []), layout: withDefault(DiagramLayout, { diff --git a/packages/diagram/core/src/types.ts b/packages/diagram/core/src/types.ts index e3ac707d..28e33174 100644 --- a/packages/diagram/core/src/types.ts +++ b/packages/diagram/core/src/types.ts @@ -1,3 +1,14 @@ -export const DIAGRAM_TYPES = ["flowchart", "mindmap"] as const; +/** + * Every canonical diagram family. A family listed here must complete each + * pipeline stage in docs/diagram-families.md; type-structure.test.ts and the + * family pipeline test fail when one is missing. + */ +export const DIAGRAM_TYPES = ["flowchart", "mindmap", "sequence"] as const; export type DiagramTypeValue = (typeof DIAGRAM_TYPES)[number]; + +/** Families expressed as the node/edge `IntermediateDiagram` graph. */ +export const GRAPH_DIAGRAM_TYPES = [ + "flowchart", + "mindmap", +] as const satisfies readonly DiagramTypeValue[]; diff --git a/packages/diagram/core/src/types/sequence.test.ts b/packages/diagram/core/src/types/sequence.test.ts new file mode 100644 index 00000000..03dfe69b --- /dev/null +++ b/packages/diagram/core/src/types/sequence.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { parseCanonicalDiagram } from "../diagram"; +import { parseIntermediateDiagram, SKETCHI_DIAGRAM_STYLE } from "../intermediate"; +import { + type SequenceDiagram, + SequenceValidationError, + apiRequestSequence, + getSequenceValidationIssues, + parseSequenceDiagram, + sequenceDiagramType, + sequenceFixture, + sequenceLifelineId, +} from "./sequence"; + +function issueCodes(diagram: SequenceDiagram) { + return getSequenceValidationIssues(diagram).map((issue) => [issue.code, issue.path]); +} + +describe("Sequence diagram type", () => { + it("keeps participant and chronological message order in the typed fixture", () => { + expect(sequenceFixture.type).toBe(sequenceDiagramType); + expect(parseCanonicalDiagram(sequenceFixture)).toEqual(sequenceFixture); + expect(sequenceFixture.participants.map((participant) => participant.id)).toEqual([ + "customer", + "store", + "payments", + ]); + expect(sequenceFixture.messages.map((message) => message.id)).toEqual([ + "checkout", + "charge", + "charged", + "receipt", + ]); + expect(apiRequestSequence.messages.filter((message) => message.type === "return")).toHaveLength( + 3, + ); + }); + + it("defaults messages and the Sketchi style without a separate authoring shape", () => { + const diagram = parseSequenceDiagram({ + id: "lonely", + title: "Lonely participant", + type: "sequence", + participants: [{ id: "only", label: "Only" }], + }); + expect(diagram.messages).toEqual([]); + expect(diagram.style).toEqual(SKETCHI_DIAGRAM_STYLE); + }); + + it("reports every broken participant and message reference with a repair hint", () => { + expect( + issueCodes({ + ...sequenceFixture, + participants: [...sequenceFixture.participants, { id: "store", label: "Second store" }], + messages: [ + ...sequenceFixture.messages, + { id: "charge", source: "ghost", target: "store", label: "Unknown sender" }, + { id: "loop", source: "store", target: "nowhere", label: "Unknown target" }, + { id: "self", source: "store", target: "store", label: "Self message" }, + ], + }), + ).toEqual([ + ["duplicate_participant_id", "participants.[3].id"], + ["duplicate_message_id", "messages.[4].id"], + ["missing_message_source", "messages.[4].source"], + ["missing_message_target", "messages.[5].target"], + ["self_message", "messages.[6]"], + ]); + }); + + it("rejects participant ids that collide with generated lifelines", () => { + const input: SequenceDiagram = { + ...sequenceFixture, + participants: [ + { id: "api", label: "API" }, + { id: sequenceLifelineId("api"), label: "Worker" }, + ], + messages: [], + }; + expect(issueCodes(input)).toEqual([["lifeline_id_collision", "participants.[1].id"]]); + expect(() => parseSequenceDiagram(input)).toThrow(SequenceValidationError); + }); + + it("is not a node/edge graph: the graph IR refuses the sequence type", () => { + expect(() => + parseIntermediateDiagram({ + id: "sequence-as-graph", + title: "Sequence as graph", + type: "sequence", + nodes: [{ id: "a", label: "A" }], + }), + ).toThrow(/Expected "flowchart" \| "mindmap"/u); + expect(() => + parseSequenceDiagram({ + id: "graph-as-sequence", + title: "Graph as sequence", + type: "sequence", + nodes: [{ id: "a", label: "A" }], + }), + ).toThrow(/participants/u); + }); +}); diff --git a/packages/diagram/core/src/types/sequence.ts b/packages/diagram/core/src/types/sequence.ts new file mode 100644 index 00000000..072dbf29 --- /dev/null +++ b/packages/diagram/core/src/types/sequence.ts @@ -0,0 +1,223 @@ +import { Effect, Schema } from "effect"; + +import { DiagramStyle, DiagramValidationError, SKETCHI_DIAGRAM_STYLE } from "../intermediate.js"; + +export const sequenceDiagramType = "sequence" as const; + +/** A call ("message") or a response to an earlier call ("return"). */ +export const SEQUENCE_MESSAGE_TYPES = ["message", "return"] as const; +export const SEQUENCE_MESSAGE_STYLES = ["solid", "dashed"] as const; + +/** Lifelines are addressed as `:lifeline` in rendered scenes. */ +export const SEQUENCE_LIFELINE_SUFFIX = ":lifeline"; + +export function sequenceLifelineId(participantId: string): string { + return `${participantId}${SEQUENCE_LIFELINE_SUFFIX}`; +} + +export const SequenceMessageTypeSchema = Schema.Literals(SEQUENCE_MESSAGE_TYPES); +export const SequenceMessageStyleSchema = Schema.Literals(SEQUENCE_MESSAGE_STYLES); +export type SequenceMessageType = typeof SequenceMessageTypeSchema.Type; +export type SequenceMessageStyle = typeof SequenceMessageStyleSchema.Type; + +export class SequenceParticipant extends Schema.Class("SequenceParticipant")({ + id: Schema.NonEmptyString, + label: Schema.NonEmptyString, + kind: Schema.optionalKey(Schema.NonEmptyString), +}) {} +export const SequenceParticipantSchema = SequenceParticipant; + +/** One message row. Array order is chronological order. */ +export class SequenceMessage extends Schema.Class("SequenceMessage")({ + id: Schema.NonEmptyString, + source: Schema.NonEmptyString, + target: Schema.NonEmptyString, + label: Schema.NonEmptyString, + type: Schema.optionalKey(SequenceMessageTypeSchema), + style: Schema.optionalKey(SequenceMessageStyleSchema), +}) {} +export const SequenceMessageSchema = SequenceMessage; + +/** + * The canonical sequence diagram: ordered participants (left to right) and + * chronologically ordered messages between their lifelines. Every surface that + * accepts, generates, renders, or evaluates a sequence diagram uses this shape. + */ +export class SequenceDiagram extends Schema.Class("SequenceDiagram")({ + id: Schema.NonEmptyString, + title: Schema.NonEmptyString, + type: Schema.Literal(sequenceDiagramType), + participants: Schema.Array(SequenceParticipant).pipe( + Schema.mutable, + Schema.check(Schema.isMinLength(1)), + ), + messages: Schema.Array(SequenceMessage).pipe( + Schema.mutable, + Schema.withDecodingDefault(Effect.succeed([])), + ), + style: DiagramStyle.pipe( + Schema.withDecodingDefault(Effect.succeed({ ...SKETCHI_DIAGRAM_STYLE })), + ), +}) {} +export const SequenceDiagramSchema = SequenceDiagram; + +export const SequenceValidationIssueCodeSchema = Schema.Literals([ + "duplicate_participant_id", + "lifeline_id_collision", + "duplicate_message_id", + "missing_message_source", + "missing_message_target", + "self_message", +]); +export type SequenceValidationIssueCode = typeof SequenceValidationIssueCodeSchema.Type; + +export class SequenceValidationIssue extends Schema.Class( + "SequenceValidationIssue", +)({ + code: SequenceValidationIssueCodeSchema, + /** Dotted path into the diagram, for example `participants.[1].id`. */ + path: Schema.NonEmptyString, + message: Schema.NonEmptyString, + hint: Schema.NonEmptyString, +}) {} + +export function getSequenceValidationIssues(diagram: SequenceDiagram): SequenceValidationIssue[] { + const issues: SequenceValidationIssue[] = []; + const participantIndexById = new Map(); + diagram.participants.forEach((participant, index) => { + if (participantIndexById.has(participant.id)) { + issues.push({ + code: "duplicate_participant_id", + path: `participants.[${index}].id`, + message: `Participant id "${participant.id}" is duplicated.`, + hint: "Give every participant a unique stable id and update message references.", + }); + return; + } + participantIndexById.set(participant.id, index); + }); + + for (const participant of diagram.participants) { + const lifelineId = sequenceLifelineId(participant.id); + const collisionIndex = participantIndexById.get(lifelineId); + if (collisionIndex !== undefined) { + issues.push({ + code: "lifeline_id_collision", + path: `participants.[${collisionIndex}].id`, + message: `Participant id "${lifelineId}" collides with the generated lifeline for "${participant.id}".`, + hint: `Rename the participant so its id does not equal another participant id followed by ${SEQUENCE_LIFELINE_SUFFIX}.`, + }); + } + } + + const messageIds = new Set(); + diagram.messages.forEach((message, index) => { + if (messageIds.has(message.id)) { + issues.push({ + code: "duplicate_message_id", + path: `messages.[${index}].id`, + message: `Message id "${message.id}" is duplicated.`, + hint: "Give every message a unique id or omit message ids to generate them deterministically.", + }); + } + messageIds.add(message.id); + if (!participantIndexById.has(message.source)) { + issues.push({ + code: "missing_message_source", + path: `messages.[${index}].source`, + message: `Message source "${message.source}" is not a participant.`, + hint: "Use the id of a participant declared in participants.", + }); + } + if (!participantIndexById.has(message.target)) { + issues.push({ + code: "missing_message_target", + path: `messages.[${index}].target`, + message: `Message target "${message.target}" is not a participant.`, + hint: "Use the id of a participant declared in participants.", + }); + } + if (message.source === message.target) { + issues.push({ + code: "self_message", + path: `messages.[${index}]`, + message: `Message "${message.id}" is self-referential.`, + hint: "Choose a different target participant; self messages are not supported.", + }); + } + }); + return issues; +} + +export class SequenceValidationError extends DiagramValidationError { + constructor(readonly issues: readonly SequenceValidationIssue[]) { + super(issues[0]?.message ?? "Sequence diagram failed semantic validation."); + this.name = "SequenceValidationError"; + } +} + +export function validateSequenceDiagram(diagram: SequenceDiagram): SequenceDiagram { + const issues = getSequenceValidationIssues(diagram); + if (issues.length > 0) { + throw new SequenceValidationError(issues); + } + return diagram; +} + +export function parseSequenceDiagram(input: unknown): SequenceDiagram { + return validateSequenceDiagram( + Schema.decodeUnknownSync(SequenceDiagram, { errors: "all" })(input), + ); +} + +export const sequenceFixture = parseSequenceDiagram({ + id: "checkout-sequence", + title: "Checkout sequence", + type: sequenceDiagramType, + participants: [ + { id: "customer", label: "Customer" }, + { id: "store", label: "Store" }, + { id: "payments", label: "Payments" }, + ], + messages: [ + { id: "checkout", source: "customer", target: "store", label: "Submit checkout" }, + { id: "charge", source: "store", target: "payments", label: "Charge card" }, + { + id: "charged", + source: "payments", + target: "store", + label: "Payment approved", + type: "return", + }, + { + id: "receipt", + source: "store", + target: "customer", + label: "Order receipt", + type: "return", + }, + ], +}); + +/** A request path with a nested call, a fire-and-forget event, and returns. */ +export const apiRequestSequence = parseSequenceDiagram({ + id: "api-request-sequence", + title: "API request with cache miss", + type: sequenceDiagramType, + participants: [ + { id: "browser", label: "Browser" }, + { id: "api", label: "API Worker" }, + { id: "cache", label: "Cache" }, + { id: "database", label: "Database" }, + { id: "analytics", label: "Analytics" }, + ], + messages: [ + { id: "request", source: "browser", target: "api", label: "GET /orders" }, + { id: "lookup", source: "api", target: "cache", label: "Look up orders" }, + { id: "miss", source: "cache", target: "api", label: "Cache miss", type: "return" }, + { id: "query", source: "api", target: "database", label: "Query orders" }, + { id: "rows", source: "database", target: "api", label: "Order rows", type: "return" }, + { id: "track", source: "api", target: "analytics", label: "Track request" }, + { id: "response", source: "api", target: "browser", label: "200 OK", type: "return" }, + ], +}); diff --git a/packages/diagram/excalidraw/src/lib/convert.test.ts b/packages/diagram/excalidraw/src/lib/convert.test.ts index b779e8f9..7e682f7e 100644 --- a/packages/diagram/excalidraw/src/lib/convert.test.ts +++ b/packages/diagram/excalidraw/src/lib/convert.test.ts @@ -22,6 +22,7 @@ import { describe("authored node geometry", () => { it("exports a sequence with a three-line participant label without growing shapes", () => { const scene = renderSequenceDiagram({ + type: "sequence", id: "multiline-sequence", title: "Multiline sequence", participants: [ @@ -1733,6 +1734,7 @@ describe("convertSceneToExcalidraw", () => { it("accepts sequence messages that cross intermediate lifelines", () => { const scene = convertSceneToExcalidraw( renderSequenceDiagram({ + type: "sequence", id: "cross-lifeline", title: "Cross lifeline", participants: [ @@ -1821,6 +1823,7 @@ describe("bound label first paint", () => { it("wraps sequence participant headers inside the header", () => { const sequence = convertSceneToExcalidraw( renderSequenceDiagram({ + type: "sequence", id: "long-participants", title: "Long participants", participants: [ diff --git a/packages/diagram/renderer/src/diagram-types/sequence.test.ts b/packages/diagram/renderer/src/diagram-types/sequence.test.ts index 2add8953..5edf63ff 100644 --- a/packages/diagram/renderer/src/diagram-types/sequence.test.ts +++ b/packages/diagram/renderer/src/diagram-types/sequence.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "vitest"; -import { getCanvasValidationIssues } from "@sketchi/diagram-core"; +import { + type SequenceDiagram, + SequenceValidationError, + getCanvasValidationIssues, + parseSequenceDiagram, + sequenceFixture, +} from "@sketchi/diagram-core"; +import { renderDiagram } from "../diagram"; import { renderSequenceDiagram } from "../sequence"; -const sequence = { +const sequence = parseSequenceDiagram({ id: "checkout-sequence", title: "Checkout sequence", + type: "sequence", participants: [ { id: "customer", label: "Customer" }, { id: "store", label: "Store" }, @@ -17,9 +25,23 @@ const sequence = { { id: "receipt", source: "payments", target: "customer", label: "Receipt" }, ], style: { accentColor: "#000000", backgroundColor: "#ffffff" }, -} as const; +}); + +function unvalidated(overrides: Partial): SequenceDiagram { + return { ...sequence, ...overrides }; +} describe("sequence diagram renderer", () => { + it("renders the canonical fixture through the family dispatch", () => { + const scene = renderDiagram(sequenceFixture); + expect(scene).toEqual(renderSequenceDiagram(sequenceFixture)); + expect(scene.diagramId).toBe("checkout-sequence"); + expect(scene.elements.filter((element) => element.type === "arrow")).toHaveLength( + sequenceFixture.messages.length, + ); + expect(getCanvasValidationIssues(scene)).toEqual([]); + }); + it("fits three-line participant labels before positioning lifelines and messages", () => { const scene = renderSequenceDiagram({ ...sequence, @@ -66,25 +88,27 @@ describe("sequence diagram renderer", () => { ).toHaveLength(3); }); - it("rejects self messages cleanly", () => { + it("rejects self messages with the core validation error", () => { expect(() => - renderSequenceDiagram({ - ...sequence, - messages: [{ id: "self", source: "store", target: "store", label: "Retry" }], - }), - ).toThrow(/cannot target its source/); + renderSequenceDiagram( + unvalidated({ + messages: [{ id: "self", source: "store", target: "store", label: "Retry" }], + }), + ), + ).toThrow(SequenceValidationError); }); it("rejects participant ids that collide with generated lifelines", () => { expect(() => - renderSequenceDiagram({ - ...sequence, - participants: [ - { id: "api", label: "API" }, - { id: "api:lifeline", label: "Worker" }, - ], - messages: [], - }), + renderSequenceDiagram( + unvalidated({ + participants: [ + { id: "api", label: "API" }, + { id: "api:lifeline", label: "Worker" }, + ], + messages: [], + }), + ), ).toThrow(/collides with the generated lifeline/); }); }); diff --git a/packages/diagram/renderer/src/diagram.ts b/packages/diagram/renderer/src/diagram.ts new file mode 100644 index 00000000..e020f10a --- /dev/null +++ b/packages/diagram/renderer/src/diagram.ts @@ -0,0 +1,15 @@ +import type { CanonicalDiagram } from "@sketchi/diagram-core"; + +import { type RenderedDiagramScene, renderIntermediateDiagram } from "./scene.js"; +import { renderSequenceDiagram } from "./sequence.js"; + +/** Render any canonical diagram with its family's renderer. */ +export function renderDiagram(diagram: CanonicalDiagram): RenderedDiagramScene { + switch (diagram.type) { + case "flowchart": + case "mindmap": + return renderIntermediateDiagram(diagram); + case "sequence": + return renderSequenceDiagram(diagram); + } +} diff --git a/packages/diagram/renderer/src/index.ts b/packages/diagram/renderer/src/index.ts index 984fe065..032c9147 100644 --- a/packages/diagram/renderer/src/index.ts +++ b/packages/diagram/renderer/src/index.ts @@ -1,2 +1,3 @@ +export * from "./diagram.js"; export * from "./scene.js"; export * from "./sequence.js"; diff --git a/packages/diagram/renderer/src/scene.test.ts b/packages/diagram/renderer/src/scene.test.ts index b0dd3172..a0ea907a 100644 --- a/packages/diagram/renderer/src/scene.test.ts +++ b/packages/diagram/renderer/src/scene.test.ts @@ -18,6 +18,7 @@ describe("geometry bounds regressions", () => { renderIntermediateDiagram(mindmapFixture), renderIntermediateDiagram(pharmaBatchDispositionFlowchart), renderSequenceDiagram({ + type: "sequence", id: "sequence-label-fit", title: "Sequence label fit", participants: [ diff --git a/packages/diagram/renderer/src/sequence.ts b/packages/diagram/renderer/src/sequence.ts index 215fb07e..cec7f8ce 100644 --- a/packages/diagram/renderer/src/sequence.ts +++ b/packages/diagram/renderer/src/sequence.ts @@ -1,4 +1,11 @@ -import { CANVAS_SPEC_VERSION, wrapTextToWidth } from "@sketchi/diagram-core"; +import { + CANVAS_SPEC_VERSION, + SEQUENCE_LIFELINE_SUFFIX, + type SequenceDiagram, + sequenceLifelineId, + validateSequenceDiagram, + wrapTextToWidth, +} from "@sketchi/diagram-core"; import type { ArrowSceneElement, NodeSceneElement, @@ -6,32 +13,6 @@ import type { TextSceneElement, } from "./scene.js"; -export interface SequenceParticipant { - readonly id: string; - readonly label: string; - readonly kind?: string | undefined; -} - -export interface SequenceMessage { - readonly id: string; - readonly source: string; - readonly target: string; - readonly label: string; - readonly type?: string | undefined; - readonly style?: string | undefined; -} - -export interface SequenceDiagramInput { - readonly id: string; - readonly title: string; - readonly participants: readonly SequenceParticipant[]; - readonly messages: readonly SequenceMessage[]; - readonly style: { - readonly accentColor: string; - readonly backgroundColor: string; - }; -} - const PADDING = 48; const HEADER_WIDTH = 180; const HEADER_HEIGHT = 72; @@ -48,10 +29,6 @@ const LAYOUT_ALIGNMENT_EPSILON = 0.01; export const SEQUENCE_LIFELINE_ROLE = "sequence-lifeline"; -export function sequenceLifelineId(participantId: string): string { - return `${participantId}:lifeline`; -} - interface SequenceLifelineStructureNode { readonly type: "node"; readonly id: string; @@ -77,7 +54,7 @@ export function isStructurallyValidSequenceLifeline( scene: SequenceLifelineStructureScene, element: SequenceLifelineStructureNode, ): boolean { - const suffix = ":lifeline"; + const suffix = SEQUENCE_LIFELINE_SUFFIX; if ( !element.nodeId.endsWith(suffix) || element.id !== `node:${element.nodeId}` || @@ -108,18 +85,12 @@ export function isStructurallyValidSequenceLifeline( ); } -/** Render a validated semantic sequence specification without graph normalization. */ -export function renderSequenceDiagram(input: SequenceDiagramInput): RenderedDiagramScene { - const participantIds = new Set(input.participants.map((participant) => participant.id)); - for (const participant of input.participants) { - const generatedLifelineId = sequenceLifelineId(participant.id); - if (participantIds.has(generatedLifelineId)) { - throw new Error( - `Sequence participant "${generatedLifelineId}" collides with the generated lifeline for "${participant.id}".`, - ); - } - } - +/** + * Render a canonical sequence diagram: participants become ordered header + * columns with lifelines, and messages become rows in chronological order. + */ +export function renderSequenceDiagram(diagram: SequenceDiagram): RenderedDiagramScene { + const input = validateSequenceDiagram(diagram); const columnStep = HEADER_WIDTH + PARTICIPANT_GAP; const headerLabelById = new Map( input.participants.map((participant) => [ @@ -193,14 +164,9 @@ export function renderSequenceDiagram(input: SequenceDiagramInput): RenderedDiag }); const messageArrows: ArrowSceneElement[] = input.messages.map((message, index) => { - const sourceX = centerXByParticipant.get(message.source); - const targetX = centerXByParticipant.get(message.target); - if (sourceX === undefined || targetX === undefined) { - throw new Error(`Sequence message "${message.id}" references an unknown participant.`); - } - if (sourceX === targetX) { - throw new Error(`Sequence message "${message.id}" cannot target its source participant.`); - } + // Validation guarantees both participants exist and differ. + const sourceX = centerXByParticipant.get(message.source) ?? 0; + const targetX = centerXByParticipant.get(message.target) ?? 0; const y = firstMessageY + index * MESSAGE_GAP; const direction = targetX > sourceX ? 1 : -1; return { diff --git a/packages/diagram/ui/src/components/flowchart-validation-panel/flowchart-validation-panel.tsx b/packages/diagram/ui/src/components/flowchart-validation-panel/flowchart-validation-panel.tsx index eb291726..133542b7 100644 --- a/packages/diagram/ui/src/components/flowchart-validation-panel/flowchart-validation-panel.tsx +++ b/packages/diagram/ui/src/components/flowchart-validation-panel/flowchart-validation-panel.tsx @@ -1,15 +1,21 @@ export interface FlowchartValidationPanelProps { edgeCount: number; + /** Plural noun for edgeCount; sequence diagrams count messages. */ + edgeNoun?: string; intermediateMessage: string; nodeCount: number; + /** Plural noun for nodeCount; sequence diagrams count participants. */ + nodeNoun?: string; realSceneIssueCount: number; realSceneMessage: string; } export function FlowchartValidationPanel({ edgeCount, + edgeNoun = "edges", intermediateMessage, nodeCount, + nodeNoun = "nodes", realSceneIssueCount, realSceneMessage, }: FlowchartValidationPanelProps) { @@ -18,8 +24,12 @@ export function FlowchartValidationPanel({ return (
- {nodeCount} nodes - {edgeCount} edges + + {nodeCount} {nodeNoun} + + + {edgeCount} {edgeNoun} +
{intermediateMessage} diff --git a/packages/diagram/ui/src/components/generation-workspace/generation-workspace.test.tsx b/packages/diagram/ui/src/components/generation-workspace/generation-workspace.test.tsx index b7232a98..e65df10b 100644 --- a/packages/diagram/ui/src/components/generation-workspace/generation-workspace.test.tsx +++ b/packages/diagram/ui/src/components/generation-workspace/generation-workspace.test.tsx @@ -6,14 +6,14 @@ vi.mock("@excalidraw/excalidraw", () => ({ })); import * as renderer from "@sketchi/diagram-renderer"; -import { flowchartFixture } from "@sketchi/diagram-core"; +import { flowchartFixture, sequenceFixture } from "@sketchi/diagram-core"; import { GenerationWorkspace } from "./generation-workspace"; afterEach(() => vi.restoreAllMocks()); describe("GenerationWorkspace", () => { it("reuses layout and validation when only status changes", () => { - const renderDiagram = vi.spyOn(renderer, "renderIntermediateDiagram"); + const renderDiagram = vi.spyOn(renderer, "renderDiagram"); const { rerender } = render(); rerender(); expect(renderDiagram).toHaveBeenCalledTimes(1); @@ -30,4 +30,14 @@ describe("GenerationWorkspace", () => { expect(screen.getByText("Validated flowchart IR")).toBeTruthy(); expect(screen.getByText("All arrows are bound")).toBeTruthy(); }); + + it("renders a sequence diagram with participant and message counts", () => { + render(); + + expect(screen.getByRole("heading", { name: "Checkout sequence" })).toBeTruthy(); + expect(screen.getByText("3 participants")).toBeTruthy(); + expect(screen.getByText("4 messages")).toBeTruthy(); + expect(screen.getByText("Validated sequence IR")).toBeTruthy(); + expect(screen.getByText("All arrows are bound")).toBeTruthy(); + }); }); diff --git a/packages/diagram/ui/src/components/generation-workspace/generation-workspace.tsx b/packages/diagram/ui/src/components/generation-workspace/generation-workspace.tsx index ee551991..6fa7d9bd 100644 --- a/packages/diagram/ui/src/components/generation-workspace/generation-workspace.tsx +++ b/packages/diagram/ui/src/components/generation-workspace/generation-workspace.tsx @@ -1,18 +1,14 @@ import { useMemo } from "react"; -import { - type IntermediateDiagram, - parseFlowchartDiagram, - validateIntermediateDiagram, -} from "@sketchi/diagram-core"; +import { type CanonicalDiagram, validateCanonicalDiagram } from "@sketchi/diagram-core"; import { convertSceneToExcalidraw, validateExcalidrawScene } from "@sketchi/diagram-excalidraw"; -import { renderIntermediateDiagram } from "@sketchi/diagram-renderer"; +import { renderDiagram } from "@sketchi/diagram-renderer"; import { DiagramPreview } from "../diagram-preview/index.js"; import { FlowchartValidationPanel } from "../flowchart-validation-panel/index.js"; export interface GenerationWorkspaceProps { - diagram: IntermediateDiagram; + diagram: CanonicalDiagram; status?: "idle" | "generating" | "ready" | "error"; } @@ -23,22 +19,28 @@ const statusLabels = { error: "Needs attention", }; +function diagramCounts(diagram: CanonicalDiagram) { + return diagram.type === "sequence" + ? { + nodeCount: diagram.participants.length, + nodeNoun: "participants", + edgeCount: diagram.messages.length, + edgeNoun: "messages", + } + : { nodeCount: diagram.nodes.length, edgeCount: diagram.edges.length }; +} + export function GenerationWorkspace({ diagram, status = "ready" }: GenerationWorkspaceProps) { const { validationMessage, scene, realSceneIssueCount, realSceneMessage } = useMemo(() => { - let validationMessage = "Validated diagram IR"; + let validationMessage = `Validated ${diagram.type} IR`; try { - if (diagram.type === "flowchart") { - parseFlowchartDiagram(diagram); - validationMessage = "Validated flowchart IR"; - } else { - validateIntermediateDiagram(diagram); - } + validateCanonicalDiagram(diagram); } catch (error) { validationMessage = error instanceof Error ? error.message : "Diagram validation failed"; } - const scene = renderIntermediateDiagram(diagram); + const scene = renderDiagram(diagram); const realSceneValidation = validateExcalidrawScene(convertSceneToExcalidraw(scene)); const realSceneIssueCount = realSceneValidation.issues.length; const realSceneMessage = @@ -64,9 +66,8 @@ export function GenerationWorkspace({ diagram, status = "ready" }: GenerationWor diff --git a/packages/diagram/ui/src/diagram-types/sequence.stories.tsx b/packages/diagram/ui/src/diagram-types/sequence.stories.tsx new file mode 100644 index 00000000..70c4a110 --- /dev/null +++ b/packages/diagram/ui/src/diagram-types/sequence.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { apiRequestSequence, sequenceFixture } from "@sketchi/diagram-core"; + +import { GenerationWorkspace } from "../components/generation-workspace"; +import "../styles.css"; + +const meta = { + title: "Diagram Types/Sequence", + component: GenerationWorkspace, + args: { + diagram: sequenceFixture, + status: "ready", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Ready: Story = {}; + +export const ApiRequestWithCacheMiss: Story = { + args: { + diagram: apiRequestSequence, + }, +}; diff --git a/tools/project-graph.test.ts b/tools/project-graph.test.ts index 2f058c27..55ec9b06 100644 --- a/tools/project-graph.test.ts +++ b/tools/project-graph.test.ts @@ -51,10 +51,12 @@ const effectPureProjectRoots = [ ]; const effectMigrationReadyProjectRoots: string[] = []; const effectSchemaBoundaryFiles = new Set([ + "packages/diagram/core/src/diagram.ts", "packages/diagram/core/src/intermediate.ts", "packages/diagram/core/src/types/flowchart.ts", "packages/diagram/core/src/types/flowchart.test.ts", "packages/diagram/core/src/types/mindmap.ts", + "packages/diagram/core/src/types/sequence.ts", ]); const frameworkNativeProjectRoots = [ "apps/excalidraw",