From b715d824588bb0c2f3328879e974325a86015d9a Mon Sep 17 00:00:00 2001 From: omkarht Date: Mon, 13 Oct 2025 19:07:11 +0530 Subject: [PATCH] feat: add support for new arrow types and enhance use case diagram features on-behalf-of: @Mermaid-Chart --- .../mermaid/src/diagrams/usecase/styles.ts | 10 + .../rendering-elements/shapes.ts | 2 + .../rendering-elements/shapes/usecaseActor.ts | 219 ++++------ .../shapes/usecaseActorIcon.ts | 156 +++++++ packages/parser/src/language/usecase/types.ts | 14 + packages/parser/tests/usecase.test.ts | 397 ++++++++++++++++-- 6 files changed, 620 insertions(+), 178 deletions(-) create mode 100644 packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActorIcon.ts diff --git a/packages/mermaid/src/diagrams/usecase/styles.ts b/packages/mermaid/src/diagrams/usecase/styles.ts index 7657a2c35..0ff0eb9b5 100644 --- a/packages/mermaid/src/diagrams/usecase/styles.ts +++ b/packages/mermaid/src/diagrams/usecase/styles.ts @@ -12,6 +12,16 @@ const getStyles = (options: any) => font-weight: normal; } + .usecase-actor-shape line { + stroke: ${options.actorBorder}; + fill: ${options.actorBkg}; + } + .usecase-actor-shape circle, line { + stroke: ${options.actorBorder}; + fill: ${options.actorBkg}; + stroke-width: 2px; + } + .usecase { stroke: ${options.primaryColor}; fill: ${options.primaryColor}; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts index 56c0f1c22..2d288099a 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts @@ -34,6 +34,7 @@ import { lightningBolt } from './shapes/lightningBolt.js'; import { linedCylinder } from './shapes/linedCylinder.js'; import { linedWaveEdgedRect } from './shapes/linedWaveEdgedRect.js'; import { usecaseActor } from './shapes/usecaseActor.js'; +import { usecaseActorIcon } from './shapes/usecaseActorIcon.js'; import { multiRect } from './shapes/multiRect.js'; import { multiWaveEdgedRectangle } from './shapes/multiWaveEdgedRectangle.js'; import { note } from './shapes/note.js'; @@ -520,6 +521,7 @@ const generateShapeMap = () => { // Usecase diagram usecaseActor, + usecaseActorIcon, } as const; const entries = [ diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts index 0a37ac457..d1aec8472 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts @@ -1,9 +1,9 @@ import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; import type { Node } from '../../types.js'; import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; -import { getIconSVG } from '../../icons.js'; import rough from 'roughjs'; import type { D3Selection } from '../../../types.js'; +import intersect from '../intersect/index.js'; /** * Get actor styling based on metadata @@ -28,6 +28,41 @@ const getActorStyling = (metadata?: Record) => { }; }; +/** + * Create stick figure path data + * This generates the SVG path for a stick figure centered at (x, y) + */ +const createStickFigurePathD = (x: number, y: number, scale = 1.5): string => { + // Base path template (centered at origin): + // M 0 -4 C 4.4183 -4 8 -7.5817 8 -12 C 8 -16.4183 4.4183 -20 0 -20 C -4.4183 -20 -8 -16.4183 -8 -12 C -8 -7.5817 -4.4183 -4 0 -4 Z M 0 -4 V 5 M -10 14.5 L 0 5 M 10 14.5 L 0 5 M -11 0 H 11 + + // Scale all coordinates + const s = (val: number) => val * scale; + + // Translate the path to the desired position + return [ + // Head (circle using cubic bezier curves) + `M ${x + s(0)} ${y + s(-4)}`, + `C ${x + s(4.4183)} ${y + s(-4)} ${x + s(8)} ${y + s(-7.5817)} ${x + s(8)} ${y + s(-12)}`, + `C ${x + s(8)} ${y + s(-16.4183)} ${x + s(4.4183)} ${y + s(-20)} ${x + s(0)} ${y + s(-20)}`, + `C ${x + s(-4.4183)} ${y + s(-20)} ${x + s(-8)} ${y + s(-16.4183)} ${x + s(-8)} ${y + s(-12)}`, + `C ${x + s(-8)} ${y + s(-7.5817)} ${x + s(-4.4183)} ${y + s(-4)} ${x + s(0)} ${y + s(-4)}`, + 'Z', + // Body (vertical line from head to torso) + `M ${x + s(0)} ${y + s(-4)}`, + `V ${y + s(5)}`, + // Left leg + `M ${x + s(-10)} ${y + s(14.5)}`, + `L ${x + s(0)} ${y + s(5)}`, + // Right leg + `M ${x + s(10)} ${y + s(14.5)}`, + `L ${x + s(0)} ${y + s(5)}`, + // Arms (horizontal line) + `M ${x + s(-11)} ${y + s(0)}`, + `H ${x + s(11)}`, + ].join(' '); +}; + /** * Draw traditional stick figure */ @@ -38,6 +73,9 @@ const drawStickFigure = ( ): void => { const x = 0; // Center at origin const y = -10; // Adjust vertical position + actorGroup.attr('class', 'usecase-actor-shape'); + + const pathData = createStickFigurePathD(x, y); if (node.look === 'handDrawn') { // @ts-expect-error -- Passing a D3.Selection seems to work for some reason @@ -48,164 +86,38 @@ const drawStickFigure = ( fill: styling.fillColor, }); - // Head (circle) - const head = rc.circle(x, y - 30, 16, options); - actorGroup.insert(() => head, ':first-child'); - - // Body (line) - const body = rc.line(x, y - 22, x, y + 10, options); - actorGroup.insert(() => body, ':first-child'); - - // Arms (line) - const arms = rc.line(x - 15, y - 10, x + 15, y - 10, options); - actorGroup.insert(() => arms, ':first-child'); - - // Left leg - const leftLeg = rc.line(x, y + 10, x - 15, y + 30, options); - actorGroup.insert(() => leftLeg, ':first-child'); - - // Right leg - const rightLeg = rc.line(x, y + 10, x + 15, y + 30, options); - actorGroup.insert(() => rightLeg, ':first-child'); + // Draw the stick figure using the path + const stickFigure = rc.path(pathData, options); + actorGroup.insert(() => stickFigure, ':first-child'); } else { - // Head (circle) + // Draw the stick figure using standard SVG path actorGroup - .append('circle') - .attr('cx', x) - .attr('cy', y - 30) - .attr('r', 8) + .append('path') + .attr('d', pathData) .attr('fill', styling.fillColor) .attr('stroke', styling.strokeColor) .attr('stroke-width', styling.strokeWidth); - - // Body (line) - actorGroup - .append('line') - .attr('x1', x) - .attr('y1', y - 22) - .attr('x2', x) - .attr('y2', y + 10) - .attr('stroke', styling.strokeColor) - .attr('stroke-width', styling.strokeWidth); - - // Arms (line) - actorGroup - .append('line') - .attr('x1', x - 15) - .attr('y1', y - 10) - .attr('x2', x + 15) - .attr('y2', y - 10) - .attr('stroke', styling.strokeColor) - .attr('stroke-width', styling.strokeWidth); - - // Left leg - actorGroup - .append('line') - .attr('x1', x) - .attr('y1', y + 10) - .attr('x2', x - 15) - .attr('y2', y + 30) - .attr('stroke', styling.strokeColor) - .attr('stroke-width', styling.strokeWidth); - - // Right leg - actorGroup - .append('line') - .attr('x1', x) - .attr('y1', y + 10) - .attr('x2', x + 15) - .attr('y2', y + 30) - .attr('stroke', styling.strokeColor) - .attr('stroke-width', styling.strokeWidth); } }; /** - * Draw actor with icon representation - */ -const drawActorWithIcon = async ( - actorGroup: D3Selection, - iconName: string, - styling: ReturnType, - node: Node -): Promise => { - const x = 0; // Center at origin - const y = -10; // Adjust vertical position - const iconSize = 50; // Icon size - - if (node.look === 'handDrawn') { - // @ts-expect-error -- Passing a D3.Selection seems to work for some reason - const rc = rough.svg(actorGroup); - const options = userNodeOverrides(node, { - stroke: styling.strokeColor, - strokeWidth: styling.strokeWidth, - fill: styling.fillColor === 'none' ? 'white' : styling.fillColor, - }); - - // Create a rectangle background for the icon - const iconBg = rc.rectangle(x - 35, y - 40, 50, 50, options); - actorGroup.insert(() => iconBg, ':first-child'); - } else { - // Create a rectangle background for the icon - actorGroup - .append('rect') - .attr('x', x - 27.5) - .attr('y', y - 42) - .attr('width', 55) - .attr('height', 55) - .attr('rx', 5) - .attr('fill', styling.fillColor === 'none' ? 'white' : styling.fillColor) - .attr('stroke', styling.strokeColor) - .attr('stroke-width', styling.strokeWidth); - } - - // Add icon using getIconSVG (like iconCircle.ts does) - const iconElem = actorGroup.append('g').attr('class', 'actor-icon'); - iconElem.html( - `${await getIconSVG(iconName, { - height: iconSize, - width: iconSize, - fallbackPrefix: 'fa', - })}` - ); - - // Get icon bounding box for positioning - const iconBBox = iconElem.node()?.getBBox(); - if (iconBBox) { - const iconWidth = iconBBox.width; - const iconHeight = iconBBox.height; - const iconX = iconBBox.x; - const iconY = iconBBox.y; - - // Center the icon in the rectangle - iconElem.attr( - 'transform', - `translate(${-iconWidth / 2 - iconX}, ${y - 15 - iconHeight / 2 - iconY})` - ); - } -}; - -/** - * Custom shape handler for usecase actors + * Custom shape handler for usecase actors (stick figure) */ export async function usecaseActor( parent: D3Selection, node: Node ) { - const { labelStyles } = styles2String(node); + const { labelStyles, nodeStyles } = styles2String(node); + node.labelStyle = labelStyles; const { shapeSvg, bbox, label } = await labelHelper(parent, node, getNodeClasses(node)); - // Actor dimensions - const actorWidth = 80; - const actorHeight = 70; // Height for the stick figure part - // Get actor metadata from node const metadata = (node as Node & { metadata?: Record }).metadata; const styling = getActorStyling(metadata); // Create actor group - const actorGroup = shapeSvg.append('g').attr('class', 'usecase-actor-shape'); + const actorGroup = shapeSvg.append('g'); // Add metadata as data attributes for CSS styling if (metadata) { @@ -214,32 +126,43 @@ export async function usecaseActor( }); } - // Check if we should render an icon instead of stick figure - if (metadata?.icon) { - await drawActorWithIcon(actorGroup, metadata.icon, styling, node); - } else { - drawStickFigure(actorGroup, styling, node); - } + // Draw stick figure + drawStickFigure(actorGroup, styling, node); + + // Get the actual bounding box of the rendered actor + const actorBBox = actorGroup.node()?.getBBox(); + const actorHeight = actorBBox?.height ?? 70; // Actor name (always rendered below the figure) const labelY = actorHeight / 2 + 15; // Position label below the figure // Calculate label height from the actual text element - const labelBBox = label.node()?.getBBox() ?? { height: 20 }; const labelHeight = labelBBox.height + 10; // Space for label below const totalHeight = actorHeight + labelHeight; + + actorGroup.attr('transform', `translate(${0}, ${-totalHeight / 2 + 35})`); label.attr( 'transform', - `translate(${-bbox.width / 2 - (bbox.x - (bbox.left ?? 0))},${labelY / 2})` + `translate(${-bbox.width / 2 - (bbox.x - (bbox.left ?? 0))},${labelY / 2 - 15} )` ); - // Update node bounds for layout + if (nodeStyles && node.look !== 'handDrawn') { + actorGroup.selectChildren('path').attr('style', nodeStyles); + } + + // Update node bounds for layout - this will set node.width and node.height from the bounding box updateNodeBounds(node, actorGroup); - // Set explicit dimensions for layout algorithm - node.width = actorWidth; + // Override height to include label space + // Width is kept from updateNodeBounds as it correctly reflects the actor's visual width node.height = totalHeight; + // Add intersect function for edge connection points + // Use rectangular intersection since the actor has a rectangular bounding box + node.intersect = function (point) { + return intersect.rect(node, point); + }; + return shapeSvg; } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActorIcon.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActorIcon.ts new file mode 100644 index 000000000..f97dd50e1 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActorIcon.ts @@ -0,0 +1,156 @@ +import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; +import type { Node } from '../../types.js'; +import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; +import { getIconSVG } from '../../icons.js'; +import rough from 'roughjs'; +import type { D3Selection } from '../../../types.js'; +import intersect from '../intersect/index.js'; + +/** + * Get actor styling based on metadata + */ +const getActorStyling = (metadata?: Record) => { + const defaults = { + fillColor: 'none', + strokeColor: 'black', + strokeWidth: 2, + type: 'solid', + }; + + if (!metadata) { + return defaults; + } + + return { + fillColor: metadata.type === 'hollow' ? 'none' : metadata.fillColor || defaults.fillColor, + strokeColor: metadata.strokeColor || defaults.strokeColor, + strokeWidth: parseInt(metadata.strokeWidth || '2', 10), + type: metadata.type || defaults.type, + }; +}; + +/** + * Draw actor with icon representation + */ +const drawActorWithIcon = async ( + actorGroup: D3Selection, + iconName: string, + styling: ReturnType, + node: Node +): Promise => { + const x = 0; // Center at origin + const y = -10; // Adjust vertical position + const iconSize = 50; // Icon size + + if (node.look === 'handDrawn') { + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(actorGroup); + const options = userNodeOverrides(node, { + stroke: styling.strokeColor, + strokeWidth: styling.strokeWidth, + fill: styling.fillColor === 'none' ? 'white' : styling.fillColor, + }); + actorGroup.attr('class', 'usecase-icon'); + // Create a rectangle background for the icon + const iconBg = rc.rectangle(x - 35, y - 40, 50, 50, options); + actorGroup.insert(() => iconBg, ':first-child'); + } else { + // Create a rectangle background for the icon + actorGroup + .append('rect') + .attr('x', x - 27.5) + .attr('y', y - 42) + .attr('width', 55) + .attr('height', 55) + .attr('rx', 5) + .attr('fill', styling.fillColor === 'none' ? 'white' : styling.fillColor) + .attr('stroke', styling.strokeColor) + .attr('stroke-width', styling.strokeWidth); + } + + // Add icon using getIconSVG (like iconCircle.ts does) + const iconElem = actorGroup.append('g').attr('class', 'actor-icon'); + iconElem.html( + `${await getIconSVG(iconName, { + height: iconSize, + width: iconSize, + fallbackPrefix: 'fa', + })}` + ); + + // Get icon bounding box for positioning + const iconBBox = iconElem.node()?.getBBox(); + if (iconBBox) { + const iconWidth = iconBBox.width; + const iconHeight = iconBBox.height; + const iconX = iconBBox.x; + const iconY = iconBBox.y; + + // Center the icon in the rectangle + iconElem.attr( + 'transform', + `translate(${-iconWidth / 2 - iconX}, ${y - 15 - iconHeight / 2 - iconY})` + ); + } +}; + +/** + * Custom shape handler for usecase actors with icons + */ +export async function usecaseActorIcon( + parent: D3Selection, + node: Node +) { + const { labelStyles } = styles2String(node); + node.labelStyle = labelStyles; + const { shapeSvg, bbox, label } = await labelHelper(parent, node, getNodeClasses(node)); + + // Get actor metadata from node + const metadata = (node as Node & { metadata?: Record }).metadata; + const styling = getActorStyling(metadata); + + // Create actor group + const actorGroup = shapeSvg.append('g'); + + // Add metadata as data attributes for CSS styling + if (metadata) { + Object.entries(metadata).forEach(([key, value]) => { + actorGroup.attr(`data-${key}`, value); + }); + } + + // Get icon name from metadata + const iconName = metadata?.icon ?? 'user'; + await drawActorWithIcon(actorGroup, iconName, styling, node); + + // Get the actual bounding box of the rendered actor icon + const actorBBox = actorGroup.node()?.getBBox(); + const actorHeight = actorBBox?.height ?? 70; + + // Actor name (always rendered below the figure) + const labelY = actorHeight / 2 + 15; // Position label below the figure + + // Calculate label height from the actual text element + const labelBBox = label.node()?.getBBox() ?? { height: 20 }; + const labelHeight = labelBBox.height + 10; // Space for label below + const totalHeight = actorHeight + labelHeight; + label.attr( + 'transform', + `translate(${-bbox.width / 2 - (bbox.x - (bbox.left ?? 0))},${labelY / 2 - 15})` + ); + + // Update node bounds for layout - this will set node.width and node.height from the bounding box + updateNodeBounds(node, actorGroup); + + // Override height to include label space + // Width is kept from updateNodeBounds as it correctly reflects the actor's visual width + node.height = totalHeight; + + // Add intersect function for edge connection points + // Use rectangular intersection for icon actors + node.intersect = function (point) { + return intersect.rect(node, point); + }; + + return shapeSvg; +} diff --git a/packages/parser/src/language/usecase/types.ts b/packages/parser/src/language/usecase/types.ts index 31b8b4119..5b4046e77 100644 --- a/packages/parser/src/language/usecase/types.ts +++ b/packages/parser/src/language/usecase/types.ts @@ -7,6 +7,10 @@ export const ARROW_TYPE = { SOLID_ARROW: 0, // --> BACK_ARROW: 1, // <-- LINE_SOLID: 2, // -- + CIRCLE_ARROW: 3, // --o + CROSS_ARROW: 4, // --x + CIRCLE_ARROW_REVERSED: 5, // o-- + CROSS_ARROW_REVERSED: 6, // x-- } as const; export type ArrowType = (typeof ARROW_TYPE)[keyof typeof ARROW_TYPE]; @@ -17,6 +21,7 @@ export interface Actor { id: string; name: string; metadata?: ActorMetadata; + styles?: string[]; // Direct CSS styles applied to this actor } export interface UseCase { @@ -24,6 +29,8 @@ export interface UseCase { name: string; nodeId?: string; // Optional node ID (e.g., 'a' in 'a(Go through code)') systemBoundary?: string; // Optional reference to system boundary + classes?: string[]; // CSS classes applied to this use case + styles?: string[]; // Direct CSS styles applied to this use case } export type SystemBoundaryType = 'package' | 'rect'; @@ -33,6 +40,7 @@ export interface SystemBoundary { name: string; useCases: string[]; // Array of use case IDs within this boundary type?: SystemBoundaryType; // Type of boundary rendering (default: 'rect') + styles?: string[]; // Direct CSS styles applied to this system boundary } export interface Relationship { @@ -44,11 +52,17 @@ export interface Relationship { label?: string; } +export interface ClassDef { + id: string; + styles: string[]; +} + export interface UsecaseParseResult { actors: Actor[]; useCases: UseCase[]; systemBoundaries: SystemBoundary[]; relationships: Relationship[]; + classDefs?: Map; direction?: string; accDescr?: string; accTitle?: string; diff --git a/packages/parser/tests/usecase.test.ts b/packages/parser/tests/usecase.test.ts index c49ec0f26..11ef43c6e 100644 --- a/packages/parser/tests/usecase.test.ts +++ b/packages/parser/tests/usecase.test.ts @@ -1352,9 +1352,9 @@ Tester --secondary--> "Bug Testing"`; const input = `usecase actor User actor Admin -User --important--> Login -Admin <--critical-- Manage -User --optional-- Dashboard`; +User -- important --> Login +Admin <-- critical -- Manage +User -- optional -- Dashboard`; const result = parse(input); expect(result.relationships).toHaveLength(3); @@ -1367,7 +1367,7 @@ User --optional-- Dashboard`; const input = `usecase actor User User --> Login -User --important--> Manage`; +User -- important --> Manage`; const result = parse(input); expect(result.relationships).toHaveLength(2); @@ -1391,8 +1391,8 @@ User --important--> Manage`; it('should work with node ID syntax and edge labels', () => { const input = `usecase actor Developer -Developer --critical--> a(Code Review) -Developer --optional--> b(Documentation)`; +Developer -- critical --> a(Code Review) +Developer -- optional --> b(Documentation)`; const result = parse(input); expect(result.relationships).toHaveLength(2); @@ -1443,22 +1443,372 @@ actor Tester --critical--> b(testing)`; }); }); -describe('Error Handling', () => { - describe('Syntax Error Handling', () => { - it('should throw UsecaseParseError for invalid syntax', () => { - const invalidSyntax = `usecase - invalid syntax here - actor User - `; +describe('New Arrow Types (--o and --x)', () => { + const parse = (input: string): UsecaseParseResult => { + return parseUsecaseWithAntlr(input); + }; - expect(() => parseUsecaseWithAntlr(invalidSyntax)).toThrow(UsecaseParseError); - expect(() => parseUsecaseWithAntlr(invalidSyntax)).toThrow(/Syntax error in usecase diagram/); + it('should parse circle arrow (--o) without label', () => { + const input = `usecase +actor Developer +Developer --o coding`; + + const result = parse(input); + expect(result.relationships).toHaveLength(1); + expect(result.relationships[0]).toEqual({ + id: 'rel_0', + from: 'Developer', + to: 'coding', + type: 'association', + arrowType: ARROW_TYPE.CIRCLE_ARROW, + }); + }); + + it('should parse circle arrow (--o) with label', () => { + const input = `usecase +actor Developer +Developer --"performs"--o coding`; + + const result = parse(input); + expect(result.relationships).toHaveLength(1); + expect(result.relationships[0]).toEqual({ + id: 'rel_0', + from: 'Developer', + to: 'coding', + type: 'association', + arrowType: ARROW_TYPE.CIRCLE_ARROW, + label: 'performs', + }); + }); + + it('should parse cross arrow (--x) without label', () => { + const input = `usecase +actor Developer +Developer --x testing`; + + const result = parse(input); + expect(result.relationships).toHaveLength(1); + expect(result.relationships[0]).toEqual({ + id: 'rel_0', + from: 'Developer', + to: 'testing', + type: 'association', + arrowType: ARROW_TYPE.CROSS_ARROW, + }); + }); + + it('should parse cross arrow (--x) with label', () => { + const input = `usecase +actor Developer +Developer --"executes"--x testing`; + + const result = parse(input); + expect(result.relationships).toHaveLength(1); + expect(result.relationships[0]).toEqual({ + id: 'rel_0', + from: 'Developer', + to: 'testing', + type: 'association', + arrowType: ARROW_TYPE.CROSS_ARROW, + label: 'executes', + }); + }); + + it('should parse mixed arrow types in same diagram', () => { + const input = `usecase +actor Developer +Developer --> debugging +Developer --o coding +Developer --x testing`; + + const result = parse(input); + expect(result.relationships).toHaveLength(3); + expect(result.relationships[0].arrowType).toBe(ARROW_TYPE.SOLID_ARROW); + expect(result.relationships[1].arrowType).toBe(ARROW_TYPE.CIRCLE_ARROW); + expect(result.relationships[2].arrowType).toBe(ARROW_TYPE.CROSS_ARROW); + }); + + it('should parse all arrow types with labels', () => { + const input = `usecase +actor Developer +Developer --"works on"--> debugging +Developer --"performs"--o coding +Developer --"executes"--x testing`; + + const result = parse(input); + expect(result.relationships).toHaveLength(3); + + expect(result.relationships[0]).toEqual({ + id: 'rel_0', + from: 'Developer', + to: 'debugging', + type: 'association', + arrowType: ARROW_TYPE.SOLID_ARROW, + label: 'works on', }); + expect(result.relationships[1]).toEqual({ + id: 'rel_1', + from: 'Developer', + to: 'coding', + type: 'association', + arrowType: ARROW_TYPE.CIRCLE_ARROW, + label: 'performs', + }); + + expect(result.relationships[2]).toEqual({ + id: 'rel_2', + from: 'Developer', + to: 'testing', + type: 'association', + arrowType: ARROW_TYPE.CROSS_ARROW, + label: 'executes', + }); + }); + + it('should parse reversed circle arrow (o--) without label', () => { + const input = `usecase +actor Developer +Developer o-- coding`; + + const result = parse(input); + expect(result.relationships).toHaveLength(1); + expect(result.relationships[0]).toEqual({ + id: 'rel_0', + from: 'Developer', + to: 'coding', + type: 'association', + arrowType: ARROW_TYPE.CIRCLE_ARROW_REVERSED, + }); + }); + + it('should parse reversed circle arrow (o--) with label', () => { + const input = `usecase +actor Developer +Developer o--"performs"-- coding`; + + const result = parse(input); + expect(result.relationships).toHaveLength(1); + expect(result.relationships[0]).toEqual({ + id: 'rel_0', + from: 'Developer', + to: 'coding', + type: 'association', + arrowType: ARROW_TYPE.CIRCLE_ARROW_REVERSED, + label: 'performs', + }); + }); + + it('should parse reversed cross arrow (x--) without label', () => { + const input = `usecase +actor Developer +Developer x-- testing`; + + const result = parse(input); + expect(result.relationships).toHaveLength(1); + expect(result.relationships[0]).toEqual({ + id: 'rel_0', + from: 'Developer', + to: 'testing', + type: 'association', + arrowType: ARROW_TYPE.CROSS_ARROW_REVERSED, + }); + }); + + it('should parse reversed cross arrow (x--) with label', () => { + const input = `usecase +actor Developer +Developer x--"executes"-- testing`; + + const result = parse(input); + expect(result.relationships).toHaveLength(1); + expect(result.relationships[0]).toEqual({ + id: 'rel_0', + from: 'Developer', + to: 'testing', + type: 'association', + arrowType: ARROW_TYPE.CROSS_ARROW_REVERSED, + label: 'executes', + }); + }); + + it('should parse all arrow types including reversed arrows', () => { + const input = `usecase +actor Developer +Developer --> UC1 +Developer --o UC2 +Developer --x UC3 +Developer o-- UC4 +Developer x-- UC5`; + + const result = parse(input); + expect(result.relationships).toHaveLength(5); + expect(result.relationships[0].arrowType).toBe(ARROW_TYPE.SOLID_ARROW); + expect(result.relationships[1].arrowType).toBe(ARROW_TYPE.CIRCLE_ARROW); + expect(result.relationships[2].arrowType).toBe(ARROW_TYPE.CROSS_ARROW); + expect(result.relationships[3].arrowType).toBe(ARROW_TYPE.CIRCLE_ARROW_REVERSED); + expect(result.relationships[4].arrowType).toBe(ARROW_TYPE.CROSS_ARROW_REVERSED); + }); +}); + +describe('Class Definition and Application', () => { + it('should parse classDef statement', () => { + const input = `usecase + debugging + classDef case1 stroke:#f00 + `; + + const result = parseUsecaseWithAntlr(input); + + expect(result.classDefs).toBeDefined(); + expect(result.classDefs?.size).toBe(1); + expect(result.classDefs?.has('case1')).toBe(true); + const classDef = result.classDefs?.get('case1'); + expect(classDef?.id).toBe('case1'); + expect(classDef?.styles).toEqual(['stroke:#f00']); + }); + + it('should parse multiple classDef statements', () => { + const input = `usecase + debugging + coding + classDef case1 stroke:#f00 + classDef case2 stroke:#0f0 + classDef case3 stroke:#00f + `; + + const result = parseUsecaseWithAntlr(input); + + expect(result.classDefs?.size).toBe(3); + expect(result.classDefs?.has('case1')).toBe(true); + expect(result.classDefs?.has('case2')).toBe(true); + expect(result.classDefs?.has('case3')).toBe(true); + }); + + it('should parse classDef with multiple style properties', () => { + const input = `usecase + debugging + classDef case1 stroke:#f00, fill:#ff0, stroke-width:2px + `; + + const result = parseUsecaseWithAntlr(input); + + expect(result.classDefs?.size).toBe(1); + const classDef = result.classDefs?.get('case1'); + expect(classDef?.styles).toEqual(['stroke:#f00', 'fill:#ff0', 'stroke-width:2px']); + }); + + it('should parse inline class application with ::: syntax', () => { + const input = `usecase + debugging:::case1 + classDef case1 stroke:#f00 + `; + + const result = parseUsecaseWithAntlr(input); + + expect(result.useCases.length).toBe(1); + expect(result.useCases[0].id).toBe('debugging'); + expect(result.useCases[0].classes).toEqual(['case1']); + }); + + it('should parse class statement', () => { + const input = `usecase + debugging + coding + class debugging,coding case1 + classDef case1 stroke:#f00 + `; + + const result = parseUsecaseWithAntlr(input); + + expect(result.useCases.length).toBe(2); + const debugging = result.useCases.find((uc) => uc.id === 'debugging'); + const coding = result.useCases.find((uc) => uc.id === 'coding'); + expect(debugging?.classes).toEqual(['case1']); + expect(coding?.classes).toEqual(['case1']); + }); + + it('should parse inline class application within system boundary', () => { + const input = `usecase + systemBoundary tasks + debugging:::case1 + coding:::case2 + end + classDef case1 stroke:#f00 + classDef case2 stroke:#0f0 + `; + + const result = parseUsecaseWithAntlr(input); + + expect(result.useCases.length).toBe(2); + const debugging = result.useCases.find((uc) => uc.id === 'debugging'); + const coding = result.useCases.find((uc) => uc.id === 'coding'); + expect(debugging?.classes).toEqual(['case1']); + expect(coding?.classes).toEqual(['case2']); + }); + + it('should parse complete example with classes and relationships', () => { + const input = `usecase + actor Developer1 + actor Developer2 + + systemBoundary tasks + debugging:::case1 + coding:::case2 + testing:::case3 + end + + Developer1 --> debugging + Developer1 --> coding + Developer1 --> testing + Developer2 --> coding + Developer2 --> debugging + + classDef case1 stroke:#f00 + classDef case2 stroke:#0f0 + classDef case3 stroke:#00f + `; + + const result = parseUsecaseWithAntlr(input); + + expect(result.actors.length).toBe(2); + expect(result.useCases.length).toBe(3); + expect(result.systemBoundaries.length).toBe(1); + expect(result.relationships.length).toBe(5); + expect(result.classDefs?.size).toBe(3); + + const debugging = result.useCases.find((uc) => uc.id === 'debugging'); + const coding = result.useCases.find((uc) => uc.id === 'coding'); + const testing = result.useCases.find((uc) => uc.id === 'testing'); + + expect(debugging?.classes).toEqual(['case1']); + expect(coding?.classes).toEqual(['case2']); + expect(testing?.classes).toEqual(['case3']); + }); + + it('should handle multiple classes on same use case', () => { + const input = `usecase + debugging:::case1 + class debugging case2 + classDef case1 stroke:#f00 + classDef case2 fill:#ff0 + `; + + const result = parseUsecaseWithAntlr(input); + + expect(result.useCases.length).toBe(1); + const debugging = result.useCases.find((uc) => uc.id === 'debugging'); + expect(debugging?.classes).toContain('case1'); + expect(debugging?.classes).toContain('case2'); + }); +}); + +describe('Error Handling', () => { + describe('Syntax Error Handling', () => { it('should throw UsecaseParseError for incomplete relationships', () => { const incompleteSyntax = `usecase - actor User - User --> + actor User + User --> `; expect(() => parseUsecaseWithAntlr(incompleteSyntax)).toThrow(UsecaseParseError); @@ -1568,19 +1918,6 @@ describe('Error Handling', () => { }); describe('Edge Cases', () => { - it('should throw UsecaseParseError for mixed valid and invalid syntax', () => { - const mixedSyntax = `usecase - actor User - invalid line here - User --> Login - another invalid line - actor Admin - `; - - expect(() => parseUsecaseWithAntlr(mixedSyntax)).toThrow(UsecaseParseError); - expect(() => parseUsecaseWithAntlr(mixedSyntax)).toThrow(/no viable alternative/); - }); - it('should handle Unicode characters', () => { const unicodeSyntax = `usecase actor "用户"