feat: add support for new arrow types and enhance use case diagram features

on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
This commit is contained in:
omkarht
2025-10-13 19:07:11 +05:30
parent 5b2b3b8ae9
commit b715d82458
6 changed files with 620 additions and 178 deletions

View File

@@ -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};

View File

@@ -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 = [

View File

@@ -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<string, string>) => {
};
};
/**
* 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<SVGGElement>,
iconName: string,
styling: ReturnType<typeof getActorStyling>,
node: Node
): Promise<void> => {
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(
`<g>${await getIconSVG(iconName, {
height: iconSize,
width: iconSize,
fallbackPrefix: 'fa',
})}</g>`
);
// 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<T extends SVGGraphicsElement>(
parent: D3Selection<T>,
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<string, string> }).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<T extends SVGGraphicsElement>(
});
}
// 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;
}

View File

@@ -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<string, string>) => {
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<SVGGElement>,
iconName: string,
styling: ReturnType<typeof getActorStyling>,
node: Node
): Promise<void> => {
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(
`<g>${await getIconSVG(iconName, {
height: iconSize,
width: iconSize,
fallbackPrefix: 'fa',
})}</g>`
);
// 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<T extends SVGGraphicsElement>(
parent: D3Selection<T>,
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<string, string> }).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;
}

View File

@@ -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<string, ClassDef>;
direction?: string;
accDescr?: string;
accTitle?: string;

View File

@@ -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 "用户"