- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams. - Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats. - Integrated a user-friendly command-line interface for generating and previewing diagrams. - Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing. - Added unit tests to ensure reliability and functionality of the new skill. These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
1450 lines
60 KiB
JavaScript
1450 lines
60 KiB
JavaScript
// Per-rule coverage for the renderers' layout validators. The golden suite's
|
|
// negative cases mostly trip ajv SCHEMA rules; this file targets the hand-
|
|
// written LAYOUT rules (the `problems.push(...)` checks) — the layer that has
|
|
// regressed before — by mutating a valid example into exactly one violation
|
|
// and asserting the renderer exits non-zero with the expected message.
|
|
//
|
|
// It also locks the error-message CONTRACT: representative messages must carry
|
|
// both the numeric threshold and a remediation hint, since the consumer is an
|
|
// LLM that fixes the JSON from the message alone.
|
|
//
|
|
// node --test test/*.test.mjs
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const skillRoot = path.resolve(__dirname, '..');
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-rules-'));
|
|
|
|
const EXAMPLES = {
|
|
workflow: 'agent-tool-call.workflow.json',
|
|
sequence: 'cache-miss-request.sequence.json',
|
|
dataflow: 'product-analytics.dataflow.json',
|
|
lifecycle: 'agent-run.lifecycle.json',
|
|
architecture: 'web-app.architecture.json',
|
|
};
|
|
|
|
function load(mode) {
|
|
return JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
|
|
}
|
|
|
|
// Returns { code, stderr }. Never throws on non-zero exit.
|
|
function render(mode, doc) {
|
|
const input = path.join(tmp, `${mode}-${Math.abs(hash(JSON.stringify(doc)))}.json`);
|
|
const outPath = path.join(tmp, `${mode}-${Math.abs(hash(JSON.stringify(doc)))}.html`);
|
|
fs.writeFileSync(input, JSON.stringify(doc));
|
|
try {
|
|
execFileSync('node', [
|
|
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
|
input,
|
|
outPath,
|
|
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
return { code: 0, stderr: '', outPath };
|
|
} catch (err) {
|
|
return { code: err.status ?? 1, stderr: String(err.stderr || ''), outPath };
|
|
}
|
|
}
|
|
|
|
function validateCli(mode, doc, quality = 'showcase') {
|
|
const input = path.join(tmp, `${mode}-cli-${Math.abs(hash(JSON.stringify(doc)))}.json`);
|
|
fs.writeFileSync(input, JSON.stringify(doc));
|
|
try {
|
|
const stdout = execFileSync('node', [
|
|
path.join(skillRoot, 'bin', 'archify.mjs'),
|
|
'validate',
|
|
mode,
|
|
input,
|
|
'--quality',
|
|
quality,
|
|
'--json',
|
|
], { encoding: 'utf8' });
|
|
return { code: 0, result: JSON.parse(stdout) };
|
|
} catch (err) {
|
|
return {
|
|
code: err.status ?? 1,
|
|
result: JSON.parse(String(err.stdout || '{}')),
|
|
};
|
|
}
|
|
}
|
|
|
|
function hash(s) {
|
|
let h = 0;
|
|
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
return h;
|
|
}
|
|
|
|
function workflowEdgePoints(html, id) {
|
|
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const pointsAttribute = html.match(
|
|
new RegExp(`data-edge-id="${escapedId}" data-composition-points="([^"]+)"`),
|
|
)?.[1];
|
|
assert.ok(pointsAttribute, `expected rendered workflow edge points for ${id}`);
|
|
return pointsAttribute.split(';').map((point) => point.split(',').map(Number));
|
|
}
|
|
|
|
function workflowNodeRect(html, id) {
|
|
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const match = html.match(new RegExp(
|
|
`<g id="node-${escapedId}"[^>]*>[\\s\\S]*?<rect x="([^"]+)" y="([^"]+)" width="([^"]+)" height="([^"]+)"`,
|
|
));
|
|
assert.ok(match, `expected rendered workflow node rect for ${id}`);
|
|
const [, x, y, width, height] = match.map(Number);
|
|
return { x, y, width, height };
|
|
}
|
|
|
|
function boundaryFrameRect(html, index) {
|
|
const match = html.match(new RegExp(
|
|
`<rect data-graph-role="structural-frame"[^>]*data-composition-frame-id="${index}"[^>]*x="([^"]+)" y="([^"]+)" width="([^"]+)" height="([^"]+)"`,
|
|
));
|
|
assert.ok(match, `expected boundary frame ${index}`);
|
|
const [, x, y, width, height] = match.map(Number);
|
|
return { x, y, width, height };
|
|
}
|
|
|
|
function boundaryTitleMasks(html) {
|
|
return [...html.matchAll(
|
|
/<g data-graph-role="structural-frame-label"[^>]*data-composition-frame-id="(\d+)"[^>]*>[\s\S]*?<rect data-graph-role="structural-frame-label-mask" x="([^"]+)" y="([^"]+)" width="([^"]+)" height="([^"]+)"/g,
|
|
)].map((match) => ({
|
|
index: Number(match[1]),
|
|
x: Number(match[2]),
|
|
y: Number(match[3]),
|
|
width: Number(match[4]),
|
|
height: Number(match[5]),
|
|
}));
|
|
}
|
|
|
|
function rectContainsRect(outer, inner) {
|
|
return outer.x <= inner.x
|
|
&& outer.y <= inner.y
|
|
&& outer.x + outer.width >= inner.x + inner.width
|
|
&& outer.y + outer.height >= inner.y + inner.height;
|
|
}
|
|
|
|
function rectanglesOverlap(left, right) {
|
|
return left.x < right.x + right.width
|
|
&& left.x + left.width > right.x
|
|
&& left.y < right.y + right.height
|
|
&& left.y + left.height > right.y;
|
|
}
|
|
|
|
function workflowEdgeLabelPoint(html, id) {
|
|
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const match = html.match(new RegExp(
|
|
`<g data-detail="context"[^>]*data-edge-id="${escapedId}"[^>]*>[\\s\\S]*?<text x="([^"]+)" y="([^"]+)"`,
|
|
));
|
|
assert.ok(match, `expected rendered workflow edge label for ${id}`);
|
|
return match.slice(1).map(Number);
|
|
}
|
|
|
|
function axisOverlapLength(a1, a2, b1, b2) {
|
|
return Math.max(0, Math.min(Math.max(a1, a2), Math.max(b1, b2))
|
|
- Math.max(Math.min(a1, a2), Math.min(b1, b2)));
|
|
}
|
|
|
|
function workflowNodeBorderOverlap(points, rect) {
|
|
const hits = [];
|
|
for (let index = 0; index < points.length - 1; index += 1) {
|
|
const [start, end] = [points[index], points[index + 1]];
|
|
if (start[0] === end[0] && (start[0] === rect.x || start[0] === rect.x + rect.width)) {
|
|
const length = axisOverlapLength(start[1], end[1], rect.y, rect.y + rect.height);
|
|
if (length > 0) hits.push({ segment: index, length, start, end });
|
|
}
|
|
if (start[1] === end[1] && (start[1] === rect.y || start[1] === rect.y + rect.height)) {
|
|
const length = axisOverlapLength(start[0], end[0], rect.x, rect.x + rect.width);
|
|
if (length > 0) hits.push({ segment: index, length, start, end });
|
|
}
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
function assertRelationshipsAvoidAllNodeBorders(html, relationships, diagramNodes) {
|
|
for (const [edgeIndex, edge] of relationships.entries()) {
|
|
const edgeId = edge.id || `edge-${edgeIndex}`;
|
|
const points = workflowEdgePoints(html, edgeId);
|
|
for (const node of diagramNodes) {
|
|
const hits = workflowNodeBorderOverlap(points, workflowNodeRect(html, node.id));
|
|
assert.deepEqual(
|
|
hits,
|
|
[],
|
|
`workflow edge ${edgeId} runs along node ${node.id} border: ${JSON.stringify(hits)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertWorkflowEdgesAvoidAllNodeBorders(html, doc) {
|
|
assertRelationshipsAvoidAllNodeBorders(html, doc.edges, doc.nodes);
|
|
}
|
|
|
|
// [name, mode, mutate(doc), expectedSubstrings[]] — every mutation introduces
|
|
// exactly one layout violation. Each expected substring must appear in stderr.
|
|
const CASES = [
|
|
// ---- workflow layout rules ----
|
|
['workflow: unknown lane', 'workflow', (d) => { d.nodes[0].lane = 'ghost'; }, ['unknown lane "ghost"']],
|
|
['workflow: node label wider than box', 'workflow',
|
|
(d) => { d.nodes[0].label = 'An Extremely Long Node Label That Overflows'; }, ['wider than node', 'shorten the label']],
|
|
['workflow: node sublabel wider than its legible minimum', 'workflow',
|
|
(d) => { d.nodes[0].sublabel = 'This supporting sentence is much too long for one workflow node'; }, ['Sublabel', 'legible', 'increase node.width']],
|
|
['workflow: node tag wider than its legible minimum', 'workflow',
|
|
(d) => { d.nodes[0].tag = 'This tag is far too long to sit inside one workflow node box'; },
|
|
['Tag', 'legible', 'increase node.width']],
|
|
['workflow: viewBox width below schema min', 'workflow',
|
|
(d) => { d.meta.viewBox = [699, 900]; }, ['700']],
|
|
['workflow: nodes too close in a lane', 'workflow',
|
|
(d) => { d.nodes.push({ ...d.nodes[0], id: 'dupe', col: d.nodes[0].col }); }, ['less than 8px apart']],
|
|
['workflow: empty group', 'workflow',
|
|
(d) => { d.groups = [{ id: 'empty', label: 'Empty group', lane: 'ui', fromCol: 3, toCol: 4 }]; }, ['does not contain any nodes']],
|
|
['workflow: mainPath missing edge', 'workflow',
|
|
(d) => { d.mainPath = ['user', 'planner']; }, ['mainPath step "user" -> "planner" has no matching edge']],
|
|
['workflow: mainPath moves backward', 'workflow',
|
|
(d) => { d.mainPath = ['external', 'trace']; }, ['moves backward from col']],
|
|
['workflow: phase ranges overlap', 'workflow',
|
|
(d) => { d.phases[2].fromCol = d.phases[1].toCol; }, ['overlaps phase', 'start at col 4 or later']],
|
|
|
|
// ---- sequence layout rules ----
|
|
['sequence: message references unknown participant', 'sequence',
|
|
(d) => { d.messages[0].from = 'ghost'; }, ['unknown source "ghost"']],
|
|
['sequence: message y outside timeline', 'sequence',
|
|
(d) => { d.messages[0].y = 9000; }, ['outside the readable timeline', 'keep y between']],
|
|
['sequence: segment to <= from', 'sequence',
|
|
(d) => { d.segments = [{ from: 400, to: 300, label: 'bad' }]; }, ['invalid y range', 'greater than']],
|
|
['sequence: participant sublabel wider than its legible minimum', 'sequence',
|
|
(d) => { d.participants[0].sublabel = 'This supporting sentence is far too long for one sequence participant'; },
|
|
['Sublabel', 'legible', 'shorten the sublabel']],
|
|
|
|
// ---- dataflow layout rules ----
|
|
['dataflow: flow missing label', 'dataflow',
|
|
(d) => { delete d.flows[0].label; }, ['label']],
|
|
['dataflow: flow references unknown node', 'dataflow',
|
|
(d) => { d.flows[0].to = 'ghost'; }, ['unknown target "ghost"']],
|
|
['dataflow: explicit via keeps every route segment orthogonal', 'dataflow',
|
|
(d) => {
|
|
d.flows[0].via = [[195, 140], [195, 260]];
|
|
}, ['diagonal segment', 'align via[0]']],
|
|
['dataflow: node sublabel wider than its legible minimum', 'dataflow',
|
|
(d) => { d.nodes[0].sublabel = 'This supporting sentence is far too long for one data-flow node box'; },
|
|
['Sublabel', 'legible', 'increase node.width']],
|
|
['dataflow: node tag wider than its legible minimum', 'dataflow',
|
|
(d) => { d.nodes[0].tag = 'This tag is far too long to sit inside one data-flow node box'; },
|
|
['Tag', 'legible', 'increase node.width']],
|
|
|
|
// ---- lifecycle layout rules ----
|
|
['lifecycle: missing reserved main lane', 'lifecycle',
|
|
(d) => {
|
|
d.lanes = d.lanes.map((l) => (l.id === 'main' ? { ...l, id: 'primary' } : l));
|
|
d.states = d.states.map((s) => (s.lane === 'main' ? { ...s, lane: 'primary' } : s));
|
|
}, ['"main"', 'reserved']],
|
|
['lifecycle: cross-lane state overlap', 'lifecycle',
|
|
(d) => {
|
|
const approval = d.states.find((s) => s.id === 'approval');
|
|
const failed = d.states.find((s) => s.id === 'failed');
|
|
delete failed.yOffset;
|
|
failed.col = approval.col;
|
|
}, ['less than 10px apart']],
|
|
['lifecycle: viewBox height below schema min', 'lifecycle',
|
|
(d) => { d.meta.viewBox = [980, 565]; }, ['566']],
|
|
['lifecycle: state sublabel wider than its legible minimum', 'lifecycle',
|
|
(d) => { d.states[0].sublabel = 'This supporting sentence is far too long for one lifecycle state box'; },
|
|
['Sublabel', 'legible', 'increase state.width']],
|
|
['lifecycle: state tag wider than its legible minimum', 'lifecycle',
|
|
(d) => { d.states[0].tag = 'This tag is far too long to sit inside one lifecycle state box'; },
|
|
['Tag', 'legible', 'increase state.width']],
|
|
|
|
// ---- architecture layout rules ----
|
|
['architecture: components overlap', 'architecture',
|
|
(d) => { d.components[1].pos = [...d.components[0].pos]; }, ['less than 8px apart']],
|
|
['architecture: connection references unknown component', 'architecture',
|
|
(d) => { d.connections[0].to = 'ghost'; }, ['unknown target "ghost"']],
|
|
['architecture: boundary wraps unknown component', 'architecture',
|
|
(d) => { d.boundaries[0].wraps.push('ghost'); }, ['wraps unknown component "ghost"']],
|
|
['architecture: boundary title must fit inside its own frame', 'architecture',
|
|
(d) => {
|
|
d.meta.quality_profile = 'standard';
|
|
d.meta.viewBox = [500, 400];
|
|
d.components = [{
|
|
id: 'narrow',
|
|
type: 'backend',
|
|
label: 'Narrow',
|
|
pos: [330, 100],
|
|
size: [128, 60],
|
|
}];
|
|
delete d.meta.views;
|
|
d.connections = [];
|
|
d.cards = [];
|
|
d.boundaries = [{
|
|
kind: 'region',
|
|
label: 'A boundary title that cannot fit its narrow authored frame',
|
|
wraps: ['narrow'],
|
|
pad: 0,
|
|
}];
|
|
}, ['Boundary label', 'fit', 'shorten']],
|
|
['architecture: boundary title must stay inside the authored viewBox', 'architecture',
|
|
(d) => {
|
|
d.meta.quality_profile = 'standard';
|
|
d.meta.viewBox = [500, 400];
|
|
d.components = [{
|
|
id: 'near-top',
|
|
type: 'backend',
|
|
label: 'Near top',
|
|
pos: [120, 8],
|
|
size: [128, 60],
|
|
}];
|
|
delete d.meta.views;
|
|
d.connections = [];
|
|
d.cards = [];
|
|
d.boundaries = [{
|
|
kind: 'region',
|
|
label: 'Viewport title',
|
|
wraps: ['near-top'],
|
|
pad: 0,
|
|
}];
|
|
}, ['Boundary label', 'outside the viewBox', 'move wrapped components']],
|
|
['architecture: label wider than component', 'architecture',
|
|
(d) => { d.components[0].label = 'An Extremely Long Component Label Overflow'; }, ['wider than component', 'shorten the label']],
|
|
['architecture: component sublabel wider than its legible minimum', 'architecture',
|
|
(d) => { d.components[0].sublabel = 'This supporting sentence is far too long for one architecture component'; },
|
|
['Sublabel', 'legible', 'widen size']],
|
|
['architecture: component tag wider than its legible minimum', 'architecture',
|
|
(d) => { d.components[0].tag = 'This tag is far too long to sit inside one architecture component box'; },
|
|
['Tag', 'legible', 'widen size']],
|
|
['architecture: component overlap suggests fix', 'architecture',
|
|
(d) => { d.components[1].pos = [...d.components[0].pos]; }, ['Suggested fix', 'move "']],
|
|
|
|
];
|
|
|
|
for (const [name, mode, mutate, expected] of CASES) {
|
|
test(name, () => {
|
|
const doc = load(mode);
|
|
mutate(doc);
|
|
const { code, stderr } = render(mode, doc);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.doesNotMatch(stderr, /TypeError|is not a function|Cannot read/, `crashed instead of reporting:\n${stderr}`);
|
|
for (const sub of expected) {
|
|
assert.ok(stderr.includes(sub), `expected "${sub}" in stderr:\n${stderr}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('architecture: ordinary boundaries may express orthogonal overlapping memberships', () => {
|
|
const d = load('architecture');
|
|
d.boundaries[1].wraps.push('auth');
|
|
const { code, stderr, outPath } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
assert.equal(rectanglesOverlap(boundaryFrameRect(html, 0), boundaryFrameRect(html, 1)), true);
|
|
const masks = boundaryTitleMasks(html);
|
|
assert.equal(masks.length, 2);
|
|
assert.equal(rectanglesOverlap(masks[0], masks[1]), false);
|
|
});
|
|
|
|
test('architecture: profile-less v1 keeps rendering when a boundary title cannot meet strict composition', () => {
|
|
const d = load('architecture');
|
|
delete d.meta.quality_profile;
|
|
d.meta.viewBox = [500, 400];
|
|
d.components = [{
|
|
id: 'narrow',
|
|
type: 'backend',
|
|
label: 'Narrow',
|
|
pos: [330, 100],
|
|
size: [128, 60],
|
|
}];
|
|
delete d.meta.views;
|
|
d.connections = [];
|
|
d.cards = [];
|
|
d.boundaries = [{
|
|
kind: 'region',
|
|
label: 'A boundary title that cannot fit its narrow authored frame',
|
|
wraps: ['narrow'],
|
|
pad: 0,
|
|
}];
|
|
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
});
|
|
|
|
test('architecture: profile-less v1 keeps legacy boundary geometry at the top edge', () => {
|
|
const d = load('architecture');
|
|
delete d.meta.quality_profile;
|
|
d.meta.viewBox = [500, 400];
|
|
d.components = [{
|
|
id: 'near-top',
|
|
type: 'backend',
|
|
label: 'Near top',
|
|
pos: [120, 22],
|
|
size: [128, 60],
|
|
}];
|
|
delete d.meta.views;
|
|
d.connections = [];
|
|
d.cards = [];
|
|
d.boundaries = [{
|
|
kind: 'region',
|
|
label: 'Legacy top edge',
|
|
wraps: ['near-top'],
|
|
pad: 0,
|
|
}];
|
|
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
});
|
|
|
|
test('architecture: deployment ownership requires nested membership geometry to agree', () => {
|
|
const d = JSON.parse(fs.readFileSync(
|
|
path.join(skillRoot, 'examples/production-deployment.architecture.json'),
|
|
'utf8',
|
|
));
|
|
d.boundaries.find((boundary) => boundary.label === 'private application network').pad = 260;
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.notEqual(code, 0);
|
|
assert.match(stderr, /final frames partially overlap/);
|
|
assert.match(stderr, /adjust wraps, pad, or component positions/);
|
|
});
|
|
|
|
test('architecture: boundary labels reserve readable space above wrapped components', () => {
|
|
const d = load('architecture');
|
|
d.boundaries = [{
|
|
kind: 'security-group',
|
|
label: 'Tool effects and permissions',
|
|
wraps: ['lb', 'api'],
|
|
pad: 14,
|
|
}];
|
|
|
|
const { code, stderr, outPath } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
const label = html.match(
|
|
/<text data-boundary-label="" x="[^"]+" y="([^"]+)" class="t-security" font-size="[^"]+" font-weight="600">Tool effects and permissions<\/text>/,
|
|
);
|
|
assert.ok(label, 'expected the security boundary label');
|
|
const labelBaseline = Number(label[1]);
|
|
const firstWrappedNodeY = Math.min(
|
|
workflowNodeRect(html, 'lb').y,
|
|
workflowNodeRect(html, 'api').y,
|
|
);
|
|
assert.ok(
|
|
labelBaseline <= firstWrappedNodeY - 4,
|
|
`expected the boundary label baseline (${labelBaseline}) to clear the first wrapped node (${firstWrappedNodeY})`,
|
|
);
|
|
});
|
|
|
|
test('architecture: auto viewBox keeps expanded boundary titles readable at desktop scale', () => {
|
|
const d = load('architecture');
|
|
delete d.meta.viewBox;
|
|
d.meta.quality_profile = 'showcase';
|
|
delete d.meta.views;
|
|
d.components = [{
|
|
id: 'node',
|
|
type: 'backend',
|
|
label: 'Current node',
|
|
pos: [800, 100],
|
|
size: [120, 60],
|
|
}];
|
|
d.connections = [];
|
|
d.cards = [];
|
|
d.boundaries = [{
|
|
kind: 'region',
|
|
label: 'Disaster recovery boundary Disaster recovery boundary Disaster recovery boundary',
|
|
wraps: ['node'],
|
|
pad: 30,
|
|
}];
|
|
|
|
const { code, stderr, outPath } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
const viewBoxWidth = Number(html.match(/\bviewBox="0 0 ([\d.]+) [\d.]+"/)?.[1]);
|
|
const sourceFontPx = Number(html.match(/<text data-boundary-label[^>]*font-size="([\d.]+)"/)?.[1]);
|
|
const projectedFontPx = sourceFontPx * Math.min(1, 930 / viewBoxWidth);
|
|
assert.ok(
|
|
projectedFontPx >= 6,
|
|
`expected the final ${viewBoxWidth}px viewBox to project its ${sourceFontPx}px boundary title at 6px or larger, got ${projectedFontPx}px`,
|
|
);
|
|
});
|
|
|
|
test('architecture: boundary labels and their masks paint above relationship routes', () => {
|
|
const d = load('architecture');
|
|
const { code, stderr, outPath } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
const lastRoute = html.lastIndexOf('data-composition-points=');
|
|
const firstBoundaryLabel = html.indexOf('data-graph-role="structural-frame-label"');
|
|
assert.ok(lastRoute >= 0, 'expected at least one rendered relationship route');
|
|
assert.ok(firstBoundaryLabel >= 0, 'expected a foreground boundary label group');
|
|
assert.ok(
|
|
firstBoundaryLabel > lastRoute,
|
|
'boundary labels must paint after relationship routes so routes cannot cross the title text',
|
|
);
|
|
assert.match(
|
|
html.slice(firstBoundaryLabel, firstBoundaryLabel + 500),
|
|
/data-graph-role="structural-frame-label-mask"/,
|
|
'expected an opaque mask behind the foreground boundary title',
|
|
);
|
|
});
|
|
|
|
test('architecture: nested boundary title rails stay inside frames and avoid labels and nodes', () => {
|
|
const d = JSON.parse(fs.readFileSync(
|
|
path.join(skillRoot, 'examples/production-deployment.architecture.json'),
|
|
'utf8',
|
|
));
|
|
const { code, stderr, outPath } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
const masks = boundaryTitleMasks(html);
|
|
assert.equal(masks.length, d.boundaries.length);
|
|
|
|
for (const mask of masks) {
|
|
assert.ok(
|
|
rectContainsRect(boundaryFrameRect(html, mask.index), mask),
|
|
`boundary title mask ${mask.index} must stay inside its frame: ${JSON.stringify(mask)}`,
|
|
);
|
|
for (const component of d.components) {
|
|
assert.equal(
|
|
rectanglesOverlap(mask, workflowNodeRect(html, component.id)),
|
|
false,
|
|
`boundary title mask ${mask.index} overlaps component ${component.id}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (let left = 0; left < masks.length; left += 1) {
|
|
for (let right = left + 1; right < masks.length; right += 1) {
|
|
assert.equal(
|
|
rectanglesOverlap(masks[left], masks[right]),
|
|
false,
|
|
`boundary title masks ${left} and ${right} overlap`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('architecture: boundary title masks cannot obscure connection labels', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Boundary and connection labels', quality_profile: 'standard', viewBox: [700, 500] },
|
|
components: [
|
|
{ id: 'scoped', type: 'backend', label: 'Scoped', pos: [250, 100], size: [120, 60] },
|
|
{ id: 'source', type: 'external', label: 'Source', pos: [50, 300], size: [100, 50] },
|
|
{ id: 'target', type: 'external', label: 'Target', pos: [500, 300], size: [100, 50] },
|
|
],
|
|
boundaries: [{ kind: 'region', label: 'Runtime scope', wraps: ['scoped'] }],
|
|
connections: [{
|
|
from: 'source',
|
|
to: 'target',
|
|
label: 'Route label',
|
|
route: 'straight',
|
|
labelAt: [270, 88],
|
|
}],
|
|
};
|
|
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.notEqual(code, 0);
|
|
assert.match(stderr, /Boundary label "Runtime scope" overlaps connection label "Route label"/);
|
|
assert.match(stderr, /labelAt\/labelDx\/labelDy\/labelSegment/);
|
|
});
|
|
|
|
// ---- sublabel/tag shrink-to-fit: the render half of the same rule ----
|
|
// Validation only rejects text that cannot fit even at its legible minimum.
|
|
// Everything between "fits at the preferred size" and that floor must shrink,
|
|
// not overflow — otherwise the common case still paints over its neighbours.
|
|
const SHRINK_CASES = [
|
|
// [mode, mutate(doc), preferredFontSize, selector for the sublabel <text>]
|
|
['architecture', (d) => { d.components[0].sublabel = 'Browser and mobile apps'; }, 9],
|
|
['sequence', (d) => { d.participants[0].sublabel = 'long browser session'; }, 7],
|
|
['dataflow', (d) => { d.nodes[0].sublabel = 'browser SDK and mobile SDK'; }, 7],
|
|
['lifecycle', (d) => { d.states[0].sublabel = 'request accepted and queued'; }, 7],
|
|
];
|
|
|
|
for (const [mode, mutate, preferred] of SHRINK_CASES) {
|
|
test(`${mode}: an over-long sublabel shrinks to fit instead of overflowing`, () => {
|
|
const d = load(mode);
|
|
mutate(d);
|
|
const { code, stderr, outPath } = render(mode, d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
const sub = html.match(/<text data-detail="context"[^>]*font-size="([\d.]+)"[^>]*>/);
|
|
assert.ok(sub, 'expected a sublabel <text> in the rendered SVG');
|
|
const fontSize = Number(sub[1]);
|
|
assert.ok(
|
|
fontSize < preferred,
|
|
`expected the sublabel to shrink below the ${preferred}px preferred size, got ${fontSize}`,
|
|
);
|
|
assert.ok(fontSize >= 6, `expected the sublabel to stay legible, got ${fontSize}`);
|
|
});
|
|
}
|
|
|
|
const TAG_SHRINK_CASES = [
|
|
// [mode, collection, tag, preferredFontSize]
|
|
['architecture', 'components', 'owner: platform operations team', 7],
|
|
['dataflow', 'nodes', 'owner: analytics platform', 7],
|
|
['lifecycle', 'states', 'owner: platform operations pod', 7],
|
|
['workflow', 'nodes', 'owner: runtime execution squad A', 7],
|
|
];
|
|
|
|
for (const [mode, collection, tag, preferred] of TAG_SHRINK_CASES) {
|
|
test(`${mode}: an over-long tag shrinks to fit instead of overflowing`, () => {
|
|
const d = load(mode);
|
|
d[collection][0].tag = tag;
|
|
const { code, stderr, outPath } = render(mode, d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const match = html.match(new RegExp(
|
|
`<text data-detail="fine"[^>]*font-size="([\\d.]+)"[^>]*>${escapedTag}</text>`,
|
|
));
|
|
assert.ok(match, `expected a tag <text> for "${tag}" in the rendered SVG`);
|
|
const fontSize = Number(match[1]);
|
|
assert.ok(
|
|
fontSize < preferred,
|
|
`expected the tag to shrink below the ${preferred}px preferred size, got ${fontSize}`,
|
|
);
|
|
assert.ok(fontSize >= 6, `expected the tag to stay legible, got ${fontSize}`);
|
|
});
|
|
}
|
|
|
|
test('contract: a too-wide label is never redirected into sublabel', () => {
|
|
// Every renderer used to advise "move detail to sublabel" for an over-long
|
|
// label. Sublabels are measured now, so that advice would move the problem
|
|
// rather than fix it.
|
|
const LABELS = {
|
|
workflow: 'An Extremely Long Node Label That Overflows',
|
|
sequence: 'An Extremely Long Participant Label That Overflows',
|
|
dataflow: 'An Extremely Long Node Label That Overflows',
|
|
lifecycle: 'An Extremely Long State Label That Overflows',
|
|
architecture: 'An Extremely Long Component Label Overflow',
|
|
};
|
|
const FIELD = {
|
|
workflow: 'nodes', sequence: 'participants', dataflow: 'nodes',
|
|
lifecycle: 'states', architecture: 'components',
|
|
};
|
|
for (const [mode, label] of Object.entries(LABELS)) {
|
|
const d = load(mode);
|
|
d[FIELD[mode]][0].label = label;
|
|
const { code, stderr } = render(mode, d);
|
|
assert.notEqual(code, 0, `${mode}: expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.ok(stderr.includes('wider than'), `${mode}: expected a width message:\n${stderr}`);
|
|
assert.ok(
|
|
!/move detail to sublabel/.test(stderr),
|
|
`${mode}: label advice still points at the measured sublabel field:\n${stderr}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---- error-message contract: threshold + remediation, not just a path ----
|
|
test('contract: short-edge message carries both the px minimum and a fix verb', () => {
|
|
const d = load('workflow');
|
|
// Force a too-short labeled edge between adjacent same-lane columns.
|
|
d.nodes.push({ id: 'a1', lane: d.nodes[0].lane, col: 0, type: 'backend', label: 'A' });
|
|
d.nodes.push({ id: 'a2', lane: d.nodes[0].lane, col: 0, type: 'backend', label: 'B', yOffset: 30 });
|
|
d.edges.push({ from: 'a1', to: 'a2', label: 'x', route: 'straight' });
|
|
const { stderr } = render('workflow', d);
|
|
// Whatever rule fires, the messages must remain actionable (threshold + verb).
|
|
assert.match(stderr, /\d+px|at least \d+|0\.\.\d+|less than/);
|
|
});
|
|
|
|
test('contract: ajv path errors are annotated with the element id', () => {
|
|
const d = load('workflow');
|
|
d.nodes[3].colour = 'red'; // unknown property → ajv additionalProperties
|
|
const { stderr } = render('workflow', d);
|
|
// Only meaningful when ajv is installed; skip the assertion in degraded mode.
|
|
if (!/schema validation failed/.test(stderr)) return;
|
|
assert.match(stderr, /id\/label:/);
|
|
});
|
|
|
|
test('workflow: same-lane offset auto edge stays orthogonal', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'workflow',
|
|
meta: { title: 'Same-lane offset route' },
|
|
lanes: [{ id: 'main', label: 'Main lane' }],
|
|
nodes: [
|
|
{ id: 'left', lane: 'main', col: 1, type: 'backend', label: 'A', width: 32, height: 38, yOffset: -14 },
|
|
{ id: 'right', lane: 'main', col: 2, type: 'backend', label: 'B', width: 32, height: 38, yOffset: 14 },
|
|
],
|
|
edges: [{ from: 'left', to: 'right' }],
|
|
};
|
|
const { code, stderr, outPath } = render('workflow', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
assert.doesNotMatch(html, /M 236 105 L 284 133/);
|
|
assert.match(html, /M 236 105 L 260 105 L 260 133 L 284 133/);
|
|
});
|
|
|
|
test('workflow: automatic routing uses one bend and avoids every node border', () => {
|
|
const d = JSON.parse(fs.readFileSync(
|
|
path.join(skillRoot, 'test/fixtures/automatic-routing-node-border-clearance.workflow.json'),
|
|
'utf8',
|
|
));
|
|
const { code, stderr, outPath } = render('workflow', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
const points = workflowEdgePoints(html, 'stdin');
|
|
const target = workflowNodeRect(html, 'team_send');
|
|
assert.equal(points.length, 3, `expected one bend, received ${JSON.stringify(points)}`);
|
|
assert.equal(points[0][0], points[1][0], 'edge must leave the source vertically');
|
|
assert.ok(points[1][1] < points[0][1], 'edge must leave the source through its top side');
|
|
assert.equal(points[1][1], points[2][1], 'edge must enter the target horizontally');
|
|
assert.ok(points[2][0] > points[1][0], 'edge must enter the target through its left side');
|
|
assert.equal(points[2][0], target.x, 'edge must stop at the target border');
|
|
assert.ok(
|
|
points[2][1] > target.y && points[2][1] < target.y + target.height,
|
|
'edge must meet the target inside its left-side anchor range',
|
|
);
|
|
assertWorkflowEdgesAvoidAllNodeBorders(html, d);
|
|
|
|
const [labelX, labelY] = workflowEdgeLabelPoint(html, 'stdin');
|
|
assert.equal(labelX, points[0][0], 'the label should use the longer vertical segment');
|
|
assert.ok(labelY > points[1][1] && labelY < points[0][1], 'the label should stay inside that segment');
|
|
});
|
|
|
|
test('workflow: shared automatic endpoints avoid every node border', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'workflow',
|
|
meta: { title: 'Shared endpoint border invariant' },
|
|
lanes: [
|
|
{ id: 'target-lane', label: 'Target' },
|
|
{ id: 'source-lane', label: 'Sources' },
|
|
],
|
|
nodes: [
|
|
{ id: 'target', lane: 'target-lane', col: 2, type: 'backend', label: 'Target' },
|
|
{ id: 'source-left', lane: 'source-lane', col: 1, type: 'backend', label: 'Left' },
|
|
{ id: 'source-right', lane: 'source-lane', col: 3, type: 'backend', label: 'Right' },
|
|
],
|
|
edges: [
|
|
{ id: 'left-to-target', from: 'source-left', to: 'target' },
|
|
{ id: 'right-to-target', from: 'source-right', to: 'target' },
|
|
],
|
|
};
|
|
const { code, stderr, outPath } = render('workflow', d);
|
|
assert.equal(code, 0, stderr);
|
|
assertWorkflowEdgesAvoidAllNodeBorders(fs.readFileSync(outPath, 'utf8'), d);
|
|
});
|
|
|
|
test('workflow: blocked first one-bend candidate selects the clear one-bend orientation', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'workflow',
|
|
meta: { title: 'Blocked one-bend candidate', quality_profile: 'showcase' },
|
|
lanes: [
|
|
{ id: 'target-lane', label: 'Target' },
|
|
{ id: 'obstacle-lane', label: 'Obstacle' },
|
|
{ id: 'source-lane', label: 'Source' },
|
|
],
|
|
nodes: [
|
|
{ id: 'target', lane: 'target-lane', col: 3, type: 'backend', label: 'Target' },
|
|
{ id: 'obstacle', lane: 'obstacle-lane', col: 1, type: 'security', label: 'Obstacle' },
|
|
{ id: 'source', lane: 'source-lane', col: 1, type: 'backend', label: 'Source' },
|
|
],
|
|
edges: [{ id: 'clear-corner', from: 'source', to: 'target' }],
|
|
};
|
|
const { code, stderr, outPath } = render('workflow', d);
|
|
assert.equal(code, 0, stderr);
|
|
const points = workflowEdgePoints(fs.readFileSync(outPath, 'utf8'), 'clear-corner');
|
|
assert.equal(points.length, 3, `expected the alternate one-bend route, received ${JSON.stringify(points)}`);
|
|
assert.equal(points[0][1], points[1][1], 'blocked vertical-first candidate must switch to horizontal-first');
|
|
assert.ok(points[1][0] > points[0][0], 'edge must leave the source through its right side');
|
|
assert.equal(points[1][0], points[2][0], 'edge must enter the target vertically');
|
|
assert.ok(points[2][1] < points[1][1], 'edge must enter the target through its bottom side');
|
|
});
|
|
|
|
test('workflow: explicit drop routing remains authoritative over the one-bend preference', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'workflow',
|
|
meta: { title: 'Explicit route compatibility', quality_profile: 'showcase' },
|
|
lanes: [
|
|
{ id: 'target-lane', label: 'Target' },
|
|
{ id: 'source-lane', label: 'Source' },
|
|
],
|
|
nodes: [
|
|
{ id: 'target', lane: 'target-lane', col: 3, type: 'backend', label: 'Target' },
|
|
{ id: 'source', lane: 'source-lane', col: 1, type: 'backend', label: 'Source' },
|
|
],
|
|
edges: [{
|
|
id: 'authored-drop',
|
|
from: 'source',
|
|
to: 'target',
|
|
route: 'drop',
|
|
fromSide: 'top',
|
|
toSide: 'bottom',
|
|
}],
|
|
};
|
|
const { code, stderr, outPath } = render('workflow', d);
|
|
assert.equal(code, 0, stderr);
|
|
const points = workflowEdgePoints(fs.readFileSync(outPath, 'utf8'), 'authored-drop');
|
|
assert.equal(points.length, 4, `explicit drop route was replaced: ${JSON.stringify(points)}`);
|
|
assert.equal(points[0][0], points[1][0]);
|
|
assert.equal(points[1][1], points[2][1]);
|
|
assert.equal(points[2][0], points[3][0]);
|
|
});
|
|
|
|
test('workflow: authored endpoint sides that follow a border are rejected generically', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'workflow',
|
|
meta: { title: 'Authored endpoint border run' },
|
|
lanes: [
|
|
{ id: 'target-lane', label: 'Target' },
|
|
{ id: 'source-lane', label: 'Source' },
|
|
],
|
|
nodes: [
|
|
{ id: 'target', lane: 'target-lane', col: 3, type: 'backend', label: 'Target' },
|
|
{ id: 'source', lane: 'source-lane', col: 1, type: 'backend', label: 'Source' },
|
|
],
|
|
edges: [{
|
|
id: 'invalid-drop',
|
|
from: 'source',
|
|
to: 'target',
|
|
route: 'drop',
|
|
fromSide: 'right',
|
|
toSide: 'left',
|
|
}],
|
|
};
|
|
const { code, stderr } = render('workflow', d);
|
|
assert.notEqual(code, 0);
|
|
assert.match(stderr, /\[clean-flow\/endpoint-side-direction\] workflow edges\[0\] id "invalid-drop"/);
|
|
assert.match(stderr, /cross node borders perpendicularly/);
|
|
});
|
|
|
|
test('dataflow: authored endpoint sides that follow a border are rejected generically', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'dataflow',
|
|
meta: { title: 'Data-flow endpoint border run' },
|
|
stages: [{ label: 'Source' }, { label: 'Target' }],
|
|
nodes: [
|
|
{ id: 'source', stage: 0, row: 0, type: 'backend', label: 'Source' },
|
|
{ id: 'target', stage: 1, row: 2, type: 'database', label: 'Target' },
|
|
],
|
|
flows: [{
|
|
id: 'invalid-bottom',
|
|
from: 'source',
|
|
to: 'target',
|
|
label: 'payload',
|
|
route: 'bottom-channel',
|
|
fromSide: 'right',
|
|
toSide: 'left',
|
|
}],
|
|
};
|
|
const { code, stderr } = render('dataflow', d);
|
|
assert.notEqual(code, 0);
|
|
assert.match(stderr, /\[clean-flow\/endpoint-side-direction\] dataflow flows\[0\] id "invalid-bottom"/);
|
|
assert.match(stderr, /cross node borders perpendicularly/);
|
|
});
|
|
|
|
test('lifecycle: automatic cross-lane routes avoid every state border', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'lifecycle',
|
|
meta: { title: 'Lifecycle endpoint border invariant' },
|
|
lanes: [
|
|
{ id: 'main', label: 'Main' },
|
|
{ id: 'terminal', label: 'Terminal' },
|
|
],
|
|
states: [
|
|
{ id: 'target', lane: 'main', col: 3, type: 'success', label: 'Target' },
|
|
{ id: 'source', lane: 'terminal', col: 1, type: 'active', label: 'Source' },
|
|
],
|
|
transitions: [{ id: 'automatic-transition', from: 'source', to: 'target' }],
|
|
};
|
|
const { code, stderr, outPath } = render('lifecycle', d);
|
|
assert.equal(code, 0, stderr);
|
|
assertRelationshipsAvoidAllNodeBorders(
|
|
fs.readFileSync(outPath, 'utf8'),
|
|
d.transitions,
|
|
d.states,
|
|
);
|
|
});
|
|
|
|
function tangentViaLifecycle() {
|
|
return {
|
|
schema_version: 1,
|
|
diagram_type: 'lifecycle',
|
|
meta: { title: 'Legacy tangent via compatibility' },
|
|
lanes: [
|
|
{ id: 'main', label: 'Main' },
|
|
{ id: 'terminal', label: 'Terminal' },
|
|
],
|
|
states: [
|
|
{ id: 'source', lane: 'main', col: 1, type: 'active', label: 'Source' },
|
|
{ id: 'target', lane: 'terminal', col: 0, type: 'success', label: 'Target' },
|
|
],
|
|
transitions: [{
|
|
id: 'legacy-via',
|
|
from: 'source',
|
|
to: 'target',
|
|
fromSide: 'bottom',
|
|
toSide: 'top',
|
|
via: [[320, 188], [320, 430], [402, 430]],
|
|
}],
|
|
};
|
|
}
|
|
|
|
test('lifecycle: legacy tangent via is rendered exactly instead of silently rewritten', () => {
|
|
const d = tangentViaLifecycle();
|
|
const { code, stderr, outPath } = render('lifecycle', d);
|
|
assert.equal(code, 0, stderr);
|
|
assert.deepEqual(workflowEdgePoints(fs.readFileSync(outPath, 'utf8'), 'legacy-via'), [
|
|
[248, 188], [320, 188], [320, 430], [402, 430], [402, 450],
|
|
]);
|
|
});
|
|
|
|
for (const qualityProfile of ['standard', 'showcase']) {
|
|
test(`lifecycle: ${qualityProfile} keeps an authored tangent via authoritative`, () => {
|
|
const d = tangentViaLifecycle();
|
|
d.meta.quality_profile = qualityProfile;
|
|
const { code, stderr, outPath } = render('lifecycle', d);
|
|
assert.equal(code, 0, stderr);
|
|
assert.deepEqual(workflowEdgePoints(fs.readFileSync(outPath, 'utf8'), 'legacy-via'), [
|
|
[248, 188], [320, 188], [320, 430], [402, 430], [402, 450],
|
|
]);
|
|
});
|
|
|
|
test(`lifecycle: public validate accepts an authoritative authored tangent via in ${qualityProfile}`, () => {
|
|
const d = tangentViaLifecycle();
|
|
d.meta.quality_profile = qualityProfile;
|
|
const { code, result } = validateCli('lifecycle', d);
|
|
assert.equal(code, 0, JSON.stringify(result, null, 2));
|
|
assert.equal(result.ok, true);
|
|
});
|
|
}
|
|
|
|
test('workflow: explicit labelAt remains authoritative on an automatic one-bend edge', () => {
|
|
const d = JSON.parse(fs.readFileSync(
|
|
path.join(skillRoot, 'test/fixtures/automatic-routing-node-border-clearance.workflow.json'),
|
|
'utf8',
|
|
));
|
|
d.edges[0].labelAt = [350, 166];
|
|
const { code, stderr, outPath } = render('workflow', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
assert.deepEqual(workflowEdgeLabelPoint(html, 'stdin'), [350, 166]);
|
|
});
|
|
|
|
test('workflow: bounded font fitting keeps an ordinary long sublabel inside its node', () => {
|
|
const d = load('workflow');
|
|
d.nodes[0].width = 92;
|
|
d.nodes[0].sublabel = 'shell / browser / MCP';
|
|
const { code, stderr, outPath } = render('workflow', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
assert.match(html, /font-size="6\.6"[^>]*>shell \/ browser \/ MCP<\/text>/);
|
|
});
|
|
|
|
test('workflow: edge crossing a non-endpoint node is rejected', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'workflow',
|
|
meta: { title: 'Crossing edge route', quality_profile: 'standard' },
|
|
lanes: [{ id: 'main', label: 'Main lane' }],
|
|
nodes: [
|
|
{ id: 'left', lane: 'main', col: 0, type: 'backend', label: 'Left', width: 60 },
|
|
{ id: 'middle', lane: 'main', col: 2, type: 'database', label: 'Middle', width: 70 },
|
|
{ id: 'right', lane: 'main', col: 4, type: 'backend', label: 'Right', width: 60 },
|
|
],
|
|
edges: [{ from: 'left', to: 'right', route: 'straight' }],
|
|
};
|
|
const { code, stderr } = render('workflow', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /crosses node "middle"/);
|
|
assert.match(stderr, /fromSide\/toSide|channel|lane\/column/);
|
|
});
|
|
|
|
test('architecture: Clean Flow Gate rejects a connection through a component', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Opaque component crossing', quality_profile: 'standard' },
|
|
components: [
|
|
{ id: 'left', type: 'frontend', label: 'Left', pos: [60, 120], size: [100, 54] },
|
|
{ id: 'middle', type: 'security', label: 'Middle', pos: [270, 120], size: [100, 54] },
|
|
{ id: 'right', type: 'backend', label: 'Right', pos: [480, 120], size: [100, 54] },
|
|
],
|
|
connections: [{ id: 'direct', from: 'left', to: 'right', route: 'straight' }],
|
|
};
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[clean-flow\/edge-through-node\] architecture connections\[0\] id "direct"/);
|
|
assert.match(stderr, /crosses component "middle"/);
|
|
assert.match(stderr, /segment 0 .*2px clearance/);
|
|
});
|
|
|
|
test('dataflow: showcase rejects a relationship label that hides another route', () => {
|
|
const d = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', 'event-stream.dataflow.json'), 'utf8'));
|
|
const approvedReplay = d.flows.find((flow) => flow.label === 'approved replay');
|
|
delete approvedReplay.labelAt;
|
|
delete approvedReplay.labelDx;
|
|
delete approvedReplay.labelDy;
|
|
delete approvedReplay.labelSegment;
|
|
const { code, stderr } = render('dataflow', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[composition\/label-route-clearance\] showcase dataflow/);
|
|
assert.match(stderr, /approved replay.*failure sample/);
|
|
assert.match(stderr, /labelAt.*labelDx.*labelDy.*labelSegment/);
|
|
});
|
|
|
|
test('dataflow: validator and SVG share the 27px CJK/emoji classification mask', () => {
|
|
const d = load('dataflow');
|
|
const flow = d.flows[0];
|
|
flow.label = '写入🚀';
|
|
flow.classification = '机密🔐';
|
|
const { code, stderr, outPath } = render('dataflow', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
const group = html.match(/<g data-detail="context"[^>]*data-edge-id="web-clickstream"[^>]*>[\s\S]*?<\/g>/)?.[0] || '';
|
|
assert.match(group, /data-edge-label="写入🚀"/);
|
|
assert.match(group, /<rect x="[^\"]+" y="[^\"]+" width="41\.4" height="27" rx="4" class="c-mask"\/>/);
|
|
assert.match(group, />机密🔐<\/text>/);
|
|
});
|
|
|
|
test('architecture: showcase rejects a connection label that hides another route', () => {
|
|
const d = load('architecture');
|
|
d.connections[0].labelAt = [620, 330];
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[composition\/label-route-clearance\] showcase architecture/);
|
|
assert.match(stderr, /HTTPS.*lb-to-api/);
|
|
});
|
|
|
|
test('workflow: showcase rejects an edge label that hides another route', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'workflow',
|
|
meta: {
|
|
title: 'Workflow label-route clearance',
|
|
quality_profile: 'showcase',
|
|
viewBox: [720, 400],
|
|
legend: { mode: 'hidden' },
|
|
},
|
|
lanes: [
|
|
{ id: 'label', label: 'Label owner' },
|
|
{ id: 'route', label: 'Other route' },
|
|
],
|
|
nodes: [
|
|
{ id: 'a', lane: 'label', col: 0, type: 'backend', label: 'A' },
|
|
{ id: 'b', lane: 'label', col: 2, type: 'backend', label: 'B' },
|
|
{ id: 'c', lane: 'route', col: 0, type: 'backend', label: 'C' },
|
|
{ id: 'd', lane: 'route', col: 2, type: 'backend', label: 'D' },
|
|
],
|
|
edges: [
|
|
{ id: 'labeled-edge', from: 'a', to: 'b', label: 'plan', labelAt: [200, 243] },
|
|
{ id: 'other-route', from: 'c', to: 'd' },
|
|
],
|
|
};
|
|
const { code, stderr } = render('workflow', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[composition\/label-route-clearance\] showcase workflow/);
|
|
assert.match(stderr, /plan.*other-route/);
|
|
});
|
|
|
|
test('lifecycle: showcase rejects a transition label that hides another route', () => {
|
|
const d = load('lifecycle');
|
|
d.transitions[0].label = 'approval gate';
|
|
d.transitions[0].labelAt = [556, 250];
|
|
const { code, stderr } = render('lifecycle', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[composition\/label-route-clearance\] showcase lifecycle/);
|
|
assert.match(stderr, /approval gate.*review-blocked/);
|
|
});
|
|
|
|
test('sequence: showcase rejects a message label that hides an adjacent route', () => {
|
|
const d = load('sequence');
|
|
d.messages = d.messages.slice(0, 2);
|
|
d.messages[0].label = 'customer authorization context';
|
|
d.messages[0].y = 250;
|
|
d.messages[1].label = 'ok';
|
|
d.messages[1].y = 245;
|
|
d.segments = [];
|
|
d.activations = [];
|
|
const { code, stderr } = render('sequence', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[composition\/label-route-clearance\] showcase sequence/);
|
|
assert.match(stderr, /customer authorization context.*dashboard-request/);
|
|
});
|
|
|
|
function autoRoutePassThroughDocument(connection) {
|
|
return {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Auto-route pass-through regression' },
|
|
components: [
|
|
{ id: 'api', type: 'backend', label: 'API', pos: [400, 280], size: [160, 76] },
|
|
{ id: 'cache', type: 'database', label: 'Cache', pos: [645, 130], size: [130, 60] },
|
|
{ id: 'queue', type: 'cloud', label: 'Queue', pos: [880, 130] },
|
|
],
|
|
connections: [connection],
|
|
};
|
|
}
|
|
|
|
test('architecture: default auto route selects a safe orthogonal candidate around an unrelated component', () => {
|
|
const d = autoRoutePassThroughDocument({ from: 'api', to: 'queue', variant: 'dashed' });
|
|
const { code, stderr, outPath } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
assert.match(html, /data-composition-points="560,318;856,318;856,160;880,160"/);
|
|
});
|
|
|
|
test('architecture: auto route enters explicit top and bottom ports perpendicularly', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Endpoint direction regression' },
|
|
components: [
|
|
{ id: 'cli-agents', type: 'external', label: 'CLI Agents', pos: [300, 100], size: [100, 60] },
|
|
{ id: 'tasks-watch', type: 'backend', label: 'Tasks Watcher', pos: [100, 240], size: [100, 60] },
|
|
],
|
|
connections: [
|
|
{ id: 'tasks-file', from: 'cli-agents', to: 'tasks-watch', variant: 'dashed', fromSide: 'bottom', toSide: 'top' },
|
|
],
|
|
};
|
|
const { code, stderr, outPath } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
assert.match(html, /data-composition-points="350,160;350,200;150,200;150,240"/);
|
|
});
|
|
|
|
test('architecture: auto route preserves inferred side normals when the primary dogleg is blocked', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Inferred endpoint direction regression' },
|
|
components: [
|
|
{ id: 'workspace', type: 'frontend', label: 'Workspace UI', pos: [40, 300], size: [120, 60] },
|
|
{ id: 'runtime-server', type: 'backend', label: 'Runtime Server', pos: [220, 300], size: [120, 60] },
|
|
{ id: 'runtime-store', type: 'backend', label: 'Runtime Store', pos: [400, 300], size: [120, 60] },
|
|
{ id: 'stream-hub', type: 'messagebus', label: 'Terminal Stream Hub', pos: [700, 100], size: [120, 60] },
|
|
],
|
|
connections: [
|
|
{ id: 'terminal-return', from: 'stream-hub', to: 'workspace' },
|
|
],
|
|
};
|
|
const { code, stderr, outPath } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
assert.match(html, /data-composition-points="700,130;184,130;184,330;160,330"/);
|
|
assert.doesNotMatch(html, /data-composition-points="700,130;700,230;160,230;160,330"/);
|
|
});
|
|
|
|
test('architecture: explicit via cannot run tangentially into an authored top port', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Tangent endpoint regression' },
|
|
components: [
|
|
{ id: 'cli-agents', type: 'external', label: 'CLI Agents', pos: [300, 100], size: [100, 60] },
|
|
{ id: 'tasks-watch', type: 'backend', label: 'Tasks Watcher', pos: [100, 240], size: [100, 60] },
|
|
],
|
|
connections: [
|
|
{
|
|
id: 'tasks-file',
|
|
from: 'cli-agents',
|
|
to: 'tasks-watch',
|
|
variant: 'dashed',
|
|
fromSide: 'bottom',
|
|
toSide: 'top',
|
|
via: [[350, 200], [100, 200], [100, 240]],
|
|
},
|
|
],
|
|
};
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[clean-flow\/endpoint-side-direction\] architecture connections\[0\] id "tasks-file"/);
|
|
assert.match(stderr, /final segment 3 \[100, 240\] -> \[150, 240\]/);
|
|
assert.match(stderr, /toSide "top".*vertical downward from above/);
|
|
});
|
|
|
|
test('architecture: explicit orthogonal route remains authoritative when it crosses a component', () => {
|
|
const d = autoRoutePassThroughDocument({
|
|
from: 'api',
|
|
to: 'queue',
|
|
variant: 'dashed',
|
|
route: 'orthogonal-h',
|
|
});
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /connections\[0\] "api" -> "queue" crosses component "cache"/);
|
|
});
|
|
|
|
test('architecture: auto route still fails closed when doglegs and side-aware bridges are blocked', () => {
|
|
const d = autoRoutePassThroughDocument({ from: 'api', to: 'queue', variant: 'dashed' });
|
|
d.components.push({ id: 'guard', type: 'security', label: 'Guard', pos: [825, 215], size: [70, 50] });
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /connections\[0\] "api" -> "queue" crosses component "cache"/);
|
|
});
|
|
|
|
test('architecture: explicit waypoints around an obstacle remain valid by default', () => {
|
|
const d = autoRoutePassThroughDocument({
|
|
from: 'api',
|
|
to: 'queue',
|
|
variant: 'dashed',
|
|
fromSide: 'right',
|
|
toSide: 'top',
|
|
via: [[620, 318], [620, 100], [940, 100]],
|
|
});
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
});
|
|
|
|
test('architecture: showcase rejects an unrelated proper edge crossing', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Showcase crossing', quality_profile: 'showcase' },
|
|
components: [
|
|
{ id: 'a', type: 'frontend', label: 'A', pos: [60, 80], size: [60, 40] },
|
|
{ id: 'b', type: 'backend', label: 'B', pos: [360, 260], size: [60, 40] },
|
|
{ id: 'c', type: 'database', label: 'C', pos: [60, 260], size: [60, 40] },
|
|
{ id: 'd', type: 'external', label: 'D', pos: [360, 80], size: [60, 40] },
|
|
],
|
|
connections: [
|
|
{ id: 'down-right', from: 'a', to: 'b', route: 'orthogonal-h' },
|
|
{ id: 'up-right', from: 'c', to: 'd', route: 'orthogonal-v', fromSide: 'top', toSide: 'bottom' },
|
|
],
|
|
};
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[composition\/proper-crossing\] showcase architecture/);
|
|
assert.match(stderr, /connections\[0\] id "down-right"/);
|
|
assert.match(stderr, /connections\[1\] id "up-right"/);
|
|
assert.match(stderr, /at \[240, 190\]/);
|
|
assert.match(stderr, /segments 1 and 1/);
|
|
assert.match(stderr, /route\/via|fromSide\/toSide/);
|
|
});
|
|
|
|
test('architecture: showcase preserves a straight-through explicit waypoint as an authored touch', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: {
|
|
title: 'Forward-collinear waypoint compatibility',
|
|
quality_profile: 'showcase',
|
|
viewBox: [600, 320],
|
|
legend: { mode: 'hidden' },
|
|
},
|
|
components: [
|
|
{ id: 'a', type: 'backend', label: 'A', pos: [50, 100], size: [80, 60] },
|
|
{ id: 'b', type: 'backend', label: 'B', pos: [450, 100], size: [80, 60] },
|
|
{ id: 'c', type: 'backend', label: 'C', pos: [260, 0], size: [80, 60] },
|
|
{ id: 'd', type: 'backend', label: 'D', pos: [260, 200], size: [80, 60] },
|
|
],
|
|
connections: [
|
|
{ id: 'horizontal', from: 'a', to: 'b', via: [[300, 130]] },
|
|
{ id: 'vertical', from: 'c', to: 'd' },
|
|
],
|
|
};
|
|
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
});
|
|
|
|
test('architecture: standard keeps the same proper crossing renderable', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Standard crossing', quality_profile: 'standard' },
|
|
components: [
|
|
{ id: 'a', type: 'frontend', label: 'A', pos: [60, 80], size: [60, 40] },
|
|
{ id: 'b', type: 'backend', label: 'B', pos: [360, 260], size: [60, 40] },
|
|
{ id: 'c', type: 'database', label: 'C', pos: [60, 260], size: [60, 40] },
|
|
{ id: 'd', type: 'external', label: 'D', pos: [360, 80], size: [60, 40] },
|
|
],
|
|
connections: [
|
|
{ from: 'a', to: 'b', route: 'orthogonal-h' },
|
|
{ from: 'c', to: 'd', route: 'orthogonal-v', fromSide: 'top', toSide: 'bottom' },
|
|
],
|
|
};
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.equal(code, 0, stderr);
|
|
});
|
|
|
|
function ambiguousCorridorDocument(profile) {
|
|
return {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Ambiguous corridor', quality_profile: profile },
|
|
components: [
|
|
{ id: 'a', type: 'frontend', label: 'A', pos: [40, 60], size: [60, 40] },
|
|
{ id: 'b', type: 'backend', label: 'B', pos: [400, 60], size: [60, 40] },
|
|
{ id: 'c', type: 'database', label: 'C', pos: [120, 220], size: [60, 40] },
|
|
{ id: 'd', type: 'external', label: 'D', pos: [480, 220], size: [60, 40] },
|
|
],
|
|
connections: [
|
|
{ id: 'first', from: 'a', to: 'b', fromSide: 'right', toSide: 'left', route: 'straight' },
|
|
{ id: 'second', from: 'c', to: 'd', fromSide: 'top', toSide: 'top', via: [[150, 80], [390, 80], [390, 180], [510, 180]] },
|
|
],
|
|
};
|
|
}
|
|
|
|
test('architecture: showcase rejects an unrelated shared route corridor', () => {
|
|
const { code, stderr } = render('architecture', ambiguousCorridorDocument('showcase'));
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[composition\/ambiguous-corridor\] showcase architecture/);
|
|
assert.match(stderr, /connections\[0\] id "first" "a" -> "b" shares a 240px corridor with connections\[1\] id "second" "c" -> "d"/);
|
|
assert.match(stderr, /\[150, 80\] -> \[390, 80\]/);
|
|
assert.match(stderr, /do not visually merge/);
|
|
});
|
|
|
|
test('architecture: standard keeps an ambiguous corridor renderable for repair', () => {
|
|
const { code, stderr } = render('architecture', ambiguousCorridorDocument('standard'));
|
|
assert.equal(code, 0, stderr);
|
|
});
|
|
|
|
test('architecture: route rhythm warns in standard and blocks a showcase micro segment', () => {
|
|
const base = {
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Readable turn rhythm' },
|
|
components: [
|
|
{ id: 'a', type: 'frontend', label: 'A', pos: [60, 80], size: [60, 40] },
|
|
{ id: 'b', type: 'backend', label: 'B', pos: [360, 80], size: [60, 40] },
|
|
],
|
|
connections: [
|
|
{ id: 'tight', from: 'a', to: 'b', fromSide: 'right', toSide: 'bottom', via: [[125, 100], [125, 160], [390, 160]] },
|
|
],
|
|
};
|
|
const standard = structuredClone(base);
|
|
standard.meta.quality_profile = 'standard';
|
|
assert.equal(render('architecture', standard).code, 0);
|
|
|
|
const showcase = structuredClone(base);
|
|
showcase.meta.quality_profile = 'showcase';
|
|
const { code, stderr } = render('architecture', showcase);
|
|
assert.notEqual(code, 0);
|
|
assert.match(stderr, /\[composition\/micro-segment\] showcase architecture connections\[0\] id "tight"/);
|
|
assert.match(stderr, /5px source-stub segment 0/);
|
|
assert.match(stderr, /wider corridor|move the component/);
|
|
});
|
|
|
|
test('architecture: container border run is blocking in standard and showcase', () => {
|
|
for (const profile of ['standard', 'showcase']) {
|
|
const d = load('architecture');
|
|
d.meta.quality_profile = profile;
|
|
d.connections.find((connection) => connection.id === 'jwt-verification').via = [[620, 142], [620, 270], [735, 270]];
|
|
const { code, stderr } = render('architecture', d);
|
|
assert.notEqual(code, 0, `expected ${profile} to reject a border run`);
|
|
assert.match(stderr, /\[composition\/container-border-run\] architecture connections\[1\] id "jwt-verification"/);
|
|
assert.match(stderr, /security-group "sg-api :443\/:8000" top border/);
|
|
}
|
|
});
|
|
|
|
test('dataflow: stage border run is blocking and the inter-stage gutter passes', () => {
|
|
const bad = load('dataflow');
|
|
bad.flows.find((flow) => flow.id === 'web-clickstream').via = [[184, 157], [184, 271]];
|
|
const failed = render('dataflow', bad);
|
|
assert.notEqual(failed.code, 0);
|
|
assert.match(failed.stderr, /\[composition\/container-border-run\] dataflow flows\[0\] id "web-clickstream"/);
|
|
assert.match(failed.stderr, /stage "Sources" right border for 114px/);
|
|
|
|
const clean = load('dataflow');
|
|
const passed = render('dataflow', clean);
|
|
assert.equal(passed.code, 0, passed.stderr);
|
|
});
|
|
|
|
test('sequence: a message cannot masquerade as a time-segment border', () => {
|
|
const d = load('sequence');
|
|
d.messages.find((message) => message.id === 'cache-read').y = 315;
|
|
const { code, stderr } = render('sequence', d);
|
|
assert.notEqual(code, 0);
|
|
assert.match(stderr, /\[composition\/container-border-run\] sequence messages\[4\] id "cache-read"/);
|
|
assert.match(stderr, /segment "Fallback" top border/);
|
|
});
|
|
|
|
test('dataflow: Clean Flow Gate rejects a flow through an unrelated node', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'dataflow',
|
|
meta: { title: 'Opaque data node crossing', quality_profile: 'standard' },
|
|
stages: [{ label: 'Source' }, { label: 'Middle' }, { label: 'Sink' }],
|
|
nodes: [
|
|
{ id: 'left', type: 'frontend', label: 'Left', stage: 0, row: 1 },
|
|
{ id: 'middle', type: 'security', label: 'Middle', stage: 1, row: 1 },
|
|
{ id: 'right', type: 'database', label: 'Right', stage: 2, row: 1 },
|
|
],
|
|
flows: [{ id: 'direct', from: 'left', to: 'right', label: 'payload', route: 'straight', labelAt: [315, 190] }],
|
|
};
|
|
const { code, stderr } = render('dataflow', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[clean-flow\/edge-through-node\] dataflow flows\[0\] id "direct"/);
|
|
assert.match(stderr, /crosses node "middle"/);
|
|
assert.match(stderr, /stage\/row/);
|
|
});
|
|
|
|
test('lifecycle: Clean Flow Gate rejects a transition through an unrelated state', () => {
|
|
const d = {
|
|
schema_version: 1,
|
|
diagram_type: 'lifecycle',
|
|
meta: { title: 'Opaque state crossing', quality_profile: 'standard' },
|
|
lanes: [{ id: 'main', label: 'Main' }],
|
|
states: [
|
|
{ id: 'left', type: 'start', label: 'Left', lane: 'main', col: 0 },
|
|
{ id: 'middle', type: 'waiting', label: 'Middle', lane: 'main', col: 2 },
|
|
{ id: 'right', type: 'success', label: 'Right', lane: 'main', col: 4 },
|
|
],
|
|
transitions: [{ id: 'direct', from: 'left', to: 'right', route: 'straight' }],
|
|
};
|
|
const { code, stderr } = render('lifecycle', d);
|
|
assert.notEqual(code, 0, `expected non-zero exit; stderr:\n${stderr}`);
|
|
assert.match(stderr, /\[clean-flow\/edge-through-node\] lifecycle transitions\[0\] id "direct"/);
|
|
assert.match(stderr, /crosses state "middle"/);
|
|
assert.match(stderr, /col\/yOffset/);
|
|
});
|
|
|
|
test('sequence: lifelines and activation bars remain intentional pass-through geometry', () => {
|
|
const d = load('sequence');
|
|
const { code, stderr } = render('sequence', d);
|
|
assert.equal(code, 0, stderr);
|
|
assert.doesNotMatch(stderr, /Clean Flow Gate/);
|
|
});
|
|
|
|
test('sequence: segment titles render as foreground badges above their borders', () => {
|
|
const d = load('sequence');
|
|
const { code, stderr, outPath } = render('sequence', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
const firstSegment = d.segments[0];
|
|
const segmentLabelsAt = html.indexOf('<!-- Segment Labels -->');
|
|
const activationsAt = html.indexOf('<!-- Activations -->');
|
|
const messagesAt = html.indexOf('<!-- Messages -->');
|
|
|
|
assert.ok(segmentLabelsAt > activationsAt, 'segment labels should stay above lifelines, messages, and activations');
|
|
assert.ok(messagesAt > activationsAt, 'message arrows and labels should stay above activation bars');
|
|
assert.match(html, new RegExp(`data-graph-role="segment-label"[^>]*data-segment-id="0"`));
|
|
assert.match(html, new RegExp(`<text x="62" y="${firstSegment.from - 9}"[^>]*>${firstSegment.label}</text>`));
|
|
});
|
|
|
|
test('sequence: segment title badge clears a nearby first message label', () => {
|
|
const d = load('sequence');
|
|
const firstSegment = d.segments[0];
|
|
const firstMessage = d.messages[0];
|
|
firstSegment.from = 180;
|
|
firstMessage.y = firstSegment.from + 5;
|
|
|
|
const { code, stderr, outPath } = render('sequence', d);
|
|
assert.equal(code, 0, stderr);
|
|
const html = fs.readFileSync(outPath, 'utf8');
|
|
const segment = html.match(
|
|
/<g data-graph-role="segment-label" data-segment-id="0">\s*<rect x="([^"]+)" y="([^"]+)" width="([^"]+)" height="([^"]+)"/,
|
|
);
|
|
const message = html.match(new RegExp(
|
|
`<g [^>]*data-edge-id="${firstMessage.id}"[\\s\\S]*?<rect x="([^"]+)" y="([^"]+)" width="([^"]+)" height="([^"]+)"`,
|
|
));
|
|
assert.ok(segment, 'expected the first segment badge rectangle');
|
|
assert.ok(message, 'expected the first message label rectangle');
|
|
|
|
const [segmentX, segmentY, segmentW, segmentH] = segment.slice(1).map(Number);
|
|
const [messageX, messageY, messageW, messageH] = message.slice(1).map(Number);
|
|
const overlaps = segmentX < messageX + messageW
|
|
&& segmentX + segmentW > messageX
|
|
&& segmentY < messageY + messageH
|
|
&& segmentY + segmentH > messageY;
|
|
assert.equal(overlaps, false, 'segment title badge must not cover the first message label');
|
|
});
|
|
|
|
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|