mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-10-16 12:39:58 +02:00
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:
@@ -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};
|
||||
|
@@ -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 = [
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
@@ -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;
|
||||
|
@@ -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 "用户"
|
||||
|
Reference in New Issue
Block a user