Files
trackgo-fe/.agents/skills/archify/test/geometry.test.mjs
T
shancheas 166e0d40ac feat: introduce archify skill for generating architecture diagrams
- 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.
2026-08-31 11:31:29 +07:00

740 lines
32 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Unit tests for the pure geometry/text helpers that every renderer leans on.
// These are exercised only transitively by the golden byte-compares, which
// can't distinguish a geometry regression from an intentional layout change —
// so they get a direct oracle here. Zero deps: node:test + node:assert.
//
// node --test test/*.test.mjs (or: npm test)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
rectsOverlap,
segmentIntersectsRect,
segmentRectClearance,
segmentRectIntersectionLength,
collectLabelRouteClearance,
cleanEndpointSideProblems,
cleanFlowProblems,
cleanCrossingProblems,
collectAmbiguousCorridors,
cleanAmbiguousCorridorProblems,
collectBorderRuns,
cleanBorderRunProblems,
collectRouteRhythmIssues,
cleanRouteRhythmProblems,
routeBudgetMetrics,
asArray,
isFinitePoint,
anchor,
automaticPortRhythmBridge,
defaultFromSide,
defaultToSide,
chosenSide,
routeHonorsEndpointSides,
polylinePath,
roundedPath,
labelPoint,
suggestLabelObstacleFix,
suggestComponentSeparation,
} from '../renderers/shared/geometry.mjs';
import { textUnits, applyTemplate, renderSemanticSigil } from '../renderers/shared/utils.mjs';
const rect = (x, y, w, h) => ({ x, y, width: w, height: h, cx: x + w / 2, cy: y + h / 2 });
test('automaticPortRhythmBridge: near parallel ports use readable outside runs', () => {
const points = automaticPortRhythmBridge(
[742, 300],
[735, 180],
'top',
'bottom',
);
assert.deepEqual(points, [
[742, 300],
[742, 276],
[758, 276],
[758, 204],
[735, 204],
[735, 180],
]);
assert.deepEqual(collectRouteRhythmIssues({
routedRelations: [{ relation: { id: 'read' }, points }],
}), []);
});
test('rectsOverlap: separated rects do not overlap', () => {
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(20, 0, 10, 10)), false);
});
test('rectsOverlap: clearly overlapping rects overlap', () => {
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(5, 5, 10, 10)), true);
});
test('rectsOverlap: edge-touching is NOT overlap at gap 0 (<= boundary)', () => {
// a ends at x=10, b starts at x=10 — exactly touching.
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(10, 0, 10, 10), 0), false);
});
test('rectsOverlap: positive gap flags rects within that gap as too close', () => {
// 8px apart, required gap 8 → touching the threshold counts as too close.
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(18, 0, 10, 10), 8), false);
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(17, 0, 10, 10), 8), true);
});
test('rectsOverlap: negative gap shrinks the hit box (label-collision convention)', () => {
// gap -2 means rects must overlap by MORE than 2px to count — a 1px sliver
// does not. This is the sign convention the label checks rely on.
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(9, 0, 10, 10), -2), false);
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(7, 0, 10, 10), -2), true);
});
test('rectsOverlap: non-finite geometry is not an overlap', () => {
// A component authored without pos lands here as NaN. Every comparison in the
// negated form is false for NaN, so the unguarded version reported a collision
// for every pair and buried the real "must include pos" diagnostic.
const nan = rect(Number.NaN, Number.NaN, 120, 60);
assert.equal(rectsOverlap(nan, nan, 8), false);
assert.equal(rectsOverlap(nan, rect(0, 0, 10, 10), 8), false);
assert.equal(rectsOverlap(rect(0, 0, 10, 10), nan, 8), false);
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(20, 0, Number.NaN, 10)), false);
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(5, 5, 10, Number.POSITIVE_INFINITY)), false);
});
test('segmentIntersectsRect: detects an edge crossing a node box', () => {
assert.equal(segmentIntersectsRect({ start: [0, 5], end: [20, 5] }, rect(8, 0, 4, 10)), true);
assert.equal(segmentIntersectsRect({ start: [0, 20], end: [20, 20] }, rect(8, 0, 4, 10)), false);
});
test('segmentRectClearance measures horizontal, vertical, and reversed diagonal segments', () => {
const box = rect(10, 10, 10, 10);
assert.equal(segmentRectClearance({ start: [0, 6], end: [30, 6] }, box), 4);
assert.equal(segmentRectClearance({ start: [6, 0], end: [6, 30] }, box), 4);
assert.equal(segmentRectClearance({ start: [0, 0], end: [8, 8] }, box), Math.sqrt(8));
assert.equal(segmentRectClearance({ start: [8, 8], end: [0, 0] }, box), Math.sqrt(8));
assert.equal(segmentRectClearance({ start: [0, 15], end: [30, 15] }, box), 0);
});
test('label-route clearance locks tangent, sub-threshold, boundary, and reversed coordinates', () => {
const box = rect(10, 10, 10, 10);
const cases = [
{ segment: { start: [0, 10], end: [30, 10] }, clearance: 0, intersection: 10 },
{ segment: { start: [10, 0], end: [10, 30] }, clearance: 0, intersection: 10 },
{ segment: { start: [0, 0], end: [30, 30] }, clearance: 0, intersection: Math.sqrt(200) },
{ segment: { start: [0, 0], end: [10, 10] }, clearance: 0, intersection: 0 },
{ segment: { start: [0, 8.1], end: [30, 8.1] }, clearance: 1.9, intersection: 0 },
{ segment: { start: [0, 8], end: [30, 8] }, clearance: 2, intersection: 0 },
{ segment: { start: [0, 6.1], end: [30, 6.1] }, clearance: 3.9, intersection: 0 },
{ segment: { start: [0, 6], end: [30, 6] }, clearance: 4, intersection: 0 },
{ segment: { start: [0, 0], end: [5, 0] }, clearance: Math.sqrt(125), intersection: 0 },
];
for (const { segment, clearance, intersection } of cases) {
assert.ok(Math.abs(segmentRectClearance(segment, box) - clearance) < 0.000001);
assert.ok(Math.abs(segmentRectIntersectionLength(segment, box) - intersection) < 0.000001);
const reversed = { start: segment.end, end: segment.start };
assert.ok(Math.abs(segmentRectClearance(reversed, box) - clearance) < 0.000001);
assert.ok(Math.abs(segmentRectIntersectionLength(reversed, box) - intersection) < 0.000001);
}
});
test('collectLabelRouteClearance exempts only the owning relationship at an exact threshold', () => {
const owner = { id: 'owner', from: 'a', to: 'b' };
const sharedSource = { id: 'other', from: 'a', to: 'c' };
const labels = [{ relation: owner, relationIndex: 0, label: 'handoff', ...rect(80, 48, 60, 14) }];
const routedRelations = [
{ relation: owner, relationIndex: 0, points: [[20, 60], [200, 60]] },
{ relation: sharedSource, relationIndex: 1, points: [[70, 64], [150, 64]] },
];
assert.deepEqual(collectLabelRouteClearance({ labels, routedRelations, threshold: 2 }), []);
const hits = collectLabelRouteClearance({ labels, routedRelations, threshold: 4 });
assert.equal(hits.length, 1);
assert.equal(hits[0].clearance, 2);
assert.equal(hits[0].otherRelation, sharedSource);
});
test('endpoint-side direction distinguishes perpendicular entry from a tangent border run', () => {
const clean = [[350, 160], [350, 200], [150, 200], [150, 240]];
const tangent = [[350, 160], [350, 200], [100, 200], [100, 240], [150, 240]];
assert.equal(routeHonorsEndpointSides(clean, 'bottom', 'top'), true);
assert.equal(routeHonorsEndpointSides(tangent, 'bottom', 'top'), false);
const relation = { id: 'tasks-file', from: 'cli-agents', to: 'tasks-watch', fromSide: 'bottom', toSide: 'top' };
const problems = cleanEndpointSideProblems({
relations: [relation],
endpointIds: new Set(['cli-agents', 'tasks-watch']),
pathFor: () => ({ points: tangent }),
diagramType: 'architecture',
relationCollection: 'connections',
});
assert.equal(problems.length, 1);
assert.match(problems[0], /\[clean-flow\/endpoint-side-direction\] architecture connections\[0\] id "tasks-file"/);
assert.match(problems[0], /final segment 3 \[100, 240\] -> \[150, 240\]/);
assert.match(problems[0], /toSide "top".*vertical downward from above/);
});
test('endpoint-side direction can fail closed on renderer-inferred automatic sides', () => {
const relation = { id: 'terminal-return', from: 'stream-hub', to: 'workspace' };
const problems = cleanEndpointSideProblems({
relations: [relation],
endpointIds: new Set(['stream-hub', 'workspace']),
pathFor: () => ({ points: [[700, 130], [700, 230], [160, 230], [160, 330]] }),
diagramType: 'architecture',
relationCollection: 'connections',
fromSideFor: () => 'left',
toSideFor: () => 'right',
});
assert.equal(problems.length, 2);
assert.match(problems[0], /inferred fromSide "left"/);
assert.match(problems[1], /inferred toSide "right"/);
});
test('cleanFlowProblems reports collection index, ids, segment, clearance, and fix', () => {
const relations = [{ id: 'checkout', from: 'client', to: 'database' }];
const obstacles = [
{ id: 'client', ...rect(0, 0, 20, 20) },
{ id: 'proxy', ...rect(40, 0, 20, 20) },
{ id: 'database', ...rect(80, 0, 20, 20) },
];
const problems = cleanFlowProblems({
relations,
obstacles,
pathFor: () => ({ points: [[20, 10], [80, 10]] }),
diagramType: 'architecture',
relationCollection: 'connections',
obstacleKind: 'component',
routeHint: 'set route/via'
});
assert.equal(problems.length, 1);
assert.match(problems[0], /\[clean-flow\/edge-through-node\] architecture connections\[0\] id "checkout" "client" -> "database"/);
assert.match(problems[0], /crosses component "proxy"/);
assert.match(problems[0], /segment 0 \[20, 10\] -> \[80, 10\] \(2px clearance\)/);
assert.match(problems[0], /set route\/via/);
});
test('cleanFlowProblems exempts endpoints and ignores missing endpoint geometry', () => {
const endpointOnly = cleanFlowProblems({
relations: [{ from: 'a', to: 'b' }],
obstacles: [{ id: 'a', ...rect(0, 0, 20, 20) }, { id: 'b', ...rect(80, 0, 20, 20) }],
pathFor: () => ({ points: [[20, 10], [80, 10]] }),
diagramType: 'workflow',
relationCollection: 'edges',
obstacleKind: 'node'
});
assert.deepEqual(endpointOnly, []);
let pathCalled = false;
const missingEndpoint = cleanFlowProblems({
relations: [{ from: 'a', to: 'ghost' }],
obstacles: [{ id: 'a', ...rect(0, 0, 20, 20) }],
pathFor: () => { pathCalled = true; return { points: [] }; },
diagramType: 'workflow',
relationCollection: 'edges',
obstacleKind: 'node'
});
assert.deepEqual(missingEndpoint, []);
assert.equal(pathCalled, false);
});
test('cleanFlowProblems uses clearance, reports the first segment, and deduplicates an obstacle', () => {
const problems = cleanFlowProblems({
relations: [{ from: 'a', to: 'b' }],
obstacles: [
{ id: 'a', ...rect(-20, -10, 20, 20) },
{ id: 'near', ...rect(8, 1, 4, 2) },
{ id: 'b', ...rect(20, -10, 20, 20) },
],
// Both segment 0 (within the 2px halo) and segment 2 intersect `near`.
pathFor: () => ({ points: [[0, -1], [20, -1], [0, 5], [20, 5]] }),
diagramType: 'workflow',
relationCollection: 'edges',
obstacleKind: 'node'
});
assert.equal(problems.length, 1);
assert.match(problems[0], /segment 0 \[0, -1\] -> \[20, -1\]/);
});
test('cleanCrossingProblems reports one deterministic proper X in showcase', () => {
const first = { id: 'first', from: 'a', to: 'b' };
const second = { id: 'second', from: 'c', to: 'd' };
const routes = new Map([
[first, { points: [[0, 0], [100, 0], [100, 100]] }],
[second, { points: [[50, -50], [50, 50], [150, 50], [150, -50], [50, -50]] }],
]);
const problems = cleanCrossingProblems({
relations: [first, second],
endpointIds: new Set(['a', 'b', 'c', 'd']),
pathFor: (relation) => routes.get(relation),
diagramType: 'architecture',
relationCollection: 'connections',
profile: 'showcase',
routeHint: 'move a via point',
});
assert.equal(problems.length, 1);
assert.match(problems[0], /\[composition\/proper-crossing\] showcase architecture/);
assert.match(problems[0], /connections\[0\] id "first" "a" -> "b" crosses connections\[1\] id "second" "c" -> "d"/);
assert.match(problems[0], /at \[50, 0\] \(segments 0 and 0\)/);
assert.match(problems[0], /move a via point/);
});
test('cleanCrossingProblems keeps proper X as non-blocking in standard', () => {
const relations = [{ from: 'a', to: 'b' }, { from: 'c', to: 'd' }];
const routes = [[[0, 50], [100, 50]], [[50, 0], [50, 100]]];
const problems = cleanCrossingProblems({
relations,
endpointIds: new Set(['a', 'b', 'c', 'd']),
pathFor: (relation) => ({ points: routes[relations.indexOf(relation)] }),
diagramType: 'workflow',
relationCollection: 'edges',
profile: 'standard',
});
assert.deepEqual(problems, []);
});
test('cleanCrossingProblems exempts shared endpoints', () => {
const relations = [{ from: 'a', to: 'b' }, { from: 'a', to: 'c' }];
const routes = [[[0, 50], [100, 50]], [[50, 0], [50, 100]]];
const problems = cleanCrossingProblems({
relations,
endpointIds: new Set(['a', 'b', 'c']),
pathFor: (relation) => ({ points: routes[relations.indexOf(relation)] }),
diagramType: 'dataflow',
relationCollection: 'flows',
profile: 'showcase',
});
assert.deepEqual(problems, []);
});
test('cleanCrossingProblems exempts endpoint touches and collinear corridors', () => {
const relations = [
{ from: 'a', to: 'b' },
{ from: 'c', to: 'd' },
{ from: 'e', to: 'f' },
];
const routes = [
[[0, 0], [100, 0]],
[[50, 0], [50, 50]],
[[25, 0], [75, 0]],
];
const problems = cleanCrossingProblems({
relations,
endpointIds: new Set(['a', 'b', 'c', 'd', 'e', 'f']),
pathFor: (relation) => ({ points: routes[relations.indexOf(relation)] }),
diagramType: 'lifecycle',
relationCollection: 'transitions',
profile: 'showcase',
});
assert.deepEqual(problems, []);
});
test('ambiguous corridor gate reports unrelated collinear overlap with exact identities', () => {
const first = { id: 'first', from: 'a', to: 'b' };
const second = { id: 'second', from: 'c', to: 'd' };
const routes = new Map([
[first, { points: [[0, 20], [100, 20], [100, 80]] }],
[second, { points: [[40, 20], [140, 20], [140, 80]] }],
]);
const hits = collectAmbiguousCorridors({
routedRelations: [first, second].map((relation, relationIndex) => ({
relation,
relationIndex,
points: routes.get(relation).points,
})),
});
assert.equal(hits.length, 1);
assert.equal(hits[0].overlapLength, 60);
assert.deepEqual(hits[0].overlapStart, [40, 20]);
assert.deepEqual(hits[0].overlapEnd, [100, 20]);
const problems = cleanAmbiguousCorridorProblems({
relations: [first, second],
endpointIds: new Set(['a', 'b', 'c', 'd']),
pathFor: (relation) => routes.get(relation),
diagramType: 'workflow',
relationCollection: 'edges',
profile: 'showcase',
routeHint: 'move a channel',
});
assert.equal(problems.length, 1);
assert.match(problems[0], /\[composition\/ambiguous-corridor\] showcase workflow/);
assert.match(problems[0], /edges\[0\] id "first" "a" -> "b" shares a 60px corridor with edges\[1\] id "second" "c" -> "d"/);
assert.match(problems[0], /\[40, 20\] -> \[100, 20\].*move a channel/);
});
test('ambiguous corridor gate exempts shared endpoints, point touches, and overlaps below 8px', () => {
const routedRelations = [
{ relation: { from: 'a', to: 'b' }, relationIndex: 0, points: [[0, 20], [100, 20]] },
{ relation: { from: 'a', to: 'c' }, relationIndex: 1, points: [[40, 20], [140, 20]] },
{ relation: { from: 'd', to: 'e' }, relationIndex: 2, points: [[100, 20], [100, 80]] },
{ relation: { from: 'f', to: 'g' }, relationIndex: 3, points: [[94, 60], [101, 60]] },
{ relation: { from: 'h', to: 'i' }, relationIndex: 4, points: [[98, 60], [110, 60]] },
];
assert.deepEqual(collectAmbiguousCorridors({ routedRelations }), []);
});
test('ambiguous corridor gate keeps standard renderable', () => {
const relations = [{ from: 'a', to: 'b' }, { from: 'c', to: 'd' }];
const routes = [[[0, 20], [100, 20]], [[40, 20], [140, 20]]];
assert.deepEqual(cleanAmbiguousCorridorProblems({
relations,
endpointIds: new Set(['a', 'b', 'c', 'd']),
pathFor: (relation) => ({ points: routes[relations.indexOf(relation)] }),
diagramType: 'architecture',
relationCollection: 'connections',
profile: 'standard',
}), []);
});
test('cleanBorderRunProblems reports a deterministic long run on a rounded frame side', () => {
const relation = { id: 'jwt', from: 'auth', to: 'api' };
const problems = cleanBorderRunProblems({
relations: [relation],
frames: [{ id: 'private', label: 'Private tier', kind: 'security-group', x: 100, y: 80, width: 180, height: 120, radius: 8 }],
pathFor: () => ({ points: [[40, 80], [220, 80], [220, 140]] }),
diagramType: 'architecture',
relationCollection: 'connections',
profile: 'standard',
routeHint: 'move the via point',
});
assert.equal(problems.length, 1);
assert.match(problems[0], /\[composition\/container-border-run\] architecture connections\[0\] id "jwt" "auth" -> "api"/);
assert.match(problems[0], /follows security-group "Private tier" top border for 112px on segment 0 \[108, 80\] -> \[220, 80\]/);
assert.match(problems[0], /move the via point/);
});
test('border-run contract allows perpendicular crossings, point touches, and rounded corners', () => {
const frame = { id: 'stage', kind: 'stage', x: 40, y: 40, width: 120, height: 100, radius: 10 };
const routedRelations = [
{ relation: { from: 'a', to: 'b' }, relationIndex: 0, points: [[100, 10], [100, 80]] },
{ relation: { from: 'c', to: 'd' }, relationIndex: 1, points: [[20, 40], [40, 40], [40, 20]] },
{ relation: { from: 'e', to: 'f' }, relationIndex: 2, points: [[40, 40], [49, 40]] },
];
assert.deepEqual(collectBorderRuns({ routedRelations, frames: [frame] }), []);
});
test('border-run contract detects vertical frames and merges hits per relation side', () => {
const hits = collectBorderRuns({
routedRelations: [{
relation: { from: 'a', to: 'b' },
relationIndex: 3,
points: [[160, 60], [160, 110], [150, 110], [160, 110], [160, 135]],
}],
frames: [{ kind: 'lane', id: 'lane-1', x: 40, y: 40, width: 120, height: 100, radius: 10 }],
});
assert.equal(hits.length, 1);
assert.equal(hits[0].side, 'right');
assert.equal(hits[0].segmentIndex, 0);
assert.equal(hits[0].overlapLength, 70);
});
test('border-run contract merges adjacent primitives and counts any positive straight overlap', () => {
const hits = collectBorderRuns({
routedRelations: [{
relation: { from: 'a', to: 'b' },
relationIndex: 0,
points: [[52, 40], [70, 40], [90, 40], [90, 50]],
}],
frames: [{ kind: 'stage', id: 'source', x: 40, y: 40, width: 120, height: 100, radius: 10 }],
});
assert.equal(hits.length, 1);
assert.equal(hits[0].overlapLength, 38);
assert.deepEqual(hits[0].overlapStart, [52, 40]);
assert.deepEqual(hits[0].overlapEnd, [90, 40]);
});
test('routeBudgetMetrics normalizes collinear points and records neutral route evidence', () => {
const metrics = routeBudgetMetrics({
routedRelations: [
{ points: [[0, 0], [10, 0], [30, 0], [30, 8], [50, 8], [50, 30]] },
{ points: [[5, 5], [5, 5]] },
],
});
assert.deepEqual(metrics, {
maxBends: 3,
routesOverSuggestedBends: 1,
maxStretch: 80 / 80,
routesOverSuggestedStretch: 0,
minSegmentPx: 8,
minInteriorSegmentPx: 8,
shortSegmentCount: 1,
shortEndpointSegmentCount: 0,
shortInteriorSegmentCount: 1,
microSegmentCount: 0,
});
});
test('route rhythm separates ordinary endpoint stubs from cramped turns and micro segments', () => {
const issues = collectRouteRhythmIssues({
routedRelations: [
{ relation: { id: 'lane-hop', from: 'a', to: 'b' }, points: [[0, 0], [13, 0], [13, 40], [80, 40], [80, 53]] },
{ relation: { id: 'bad-turn', from: 'c', to: 'd' }, points: [[0, 80], [24, 80], [24, 89], [60, 89]] },
{ relation: { id: 'micro-stub', from: 'e', to: 'f' }, points: [[0, 120], [5, 120], [5, 180]] },
],
});
assert.deepEqual(issues.map((issue) => [issue.relation.id, issue.code, issue.position, issue.length]), [
['bad-turn', 'composition/short-interior-segment', 'interior', 9],
['micro-stub', 'composition/micro-segment', 'source-stub', 5],
]);
});
test('route rhythm is a showcase-only generation gate with actionable relationship identity', () => {
const relations = [{ id: 'events', from: 'api', to: 'bus' }];
const args = {
relations,
endpointIds: new Set(['api', 'bus']),
pathFor: () => ({ points: [[10, 20], [15, 20], [15, 80]] }),
diagramType: 'architecture',
relationCollection: 'connections',
};
assert.deepEqual(cleanRouteRhythmProblems({ ...args, profile: 'standard' }), []);
const problems = cleanRouteRhythmProblems({ ...args, profile: 'showcase' });
assert.equal(problems.length, 1);
assert.match(problems[0], /\[composition\/micro-segment\] showcase architecture connections\[0\] id "events"/);
assert.match(problems[0], /5px source-stub segment 0/);
});
test('asArray coerces non-arrays to [] (degraded-mode guard)', () => {
assert.deepEqual(asArray([1, 2]), [1, 2]);
assert.deepEqual(asArray('oops'), []);
assert.deepEqual(asArray(undefined), []);
assert.deepEqual(asArray(null), []);
assert.deepEqual(asArray({ length: 3 }), []);
});
test('isFinitePoint rejects NaN/undefined/Infinity', () => {
assert.equal(isFinitePoint(1, 2, 3, 4), true);
assert.equal(isFinitePoint(1, NaN), false);
assert.equal(isFinitePoint(1, undefined), false);
assert.equal(isFinitePoint(1, Infinity), false);
});
test('anchor returns the correct edge midpoint for each side', () => {
const r = rect(100, 100, 40, 20); // cx=120 cy=110
assert.deepEqual(anchor(r, 'left'), [100, 110]);
assert.deepEqual(anchor(r, 'right'), [140, 110]);
assert.deepEqual(anchor(r, 'top'), [120, 100]);
assert.deepEqual(anchor(r, 'bottom'), [120, 120]);
});
test('anchor falls back to the right edge for unknown/auto sides', () => {
const r = rect(100, 100, 40, 20);
assert.deepEqual(anchor(r, 'auto'), [140, 110]);
assert.deepEqual(anchor(r, undefined), [140, 110]);
});
test('defaultFromSide / defaultToSide are mirror pairs', () => {
const a = { cx: 0, cy: 0 };
const right = { cx: 100, cy: 0 };
assert.equal(defaultFromSide(a, right), 'right');
assert.equal(defaultToSide(a, right), 'left');
const below = { cx: 0, cy: 100 };
assert.equal(defaultFromSide(a, below), 'bottom');
assert.equal(defaultToSide(a, below), 'top');
});
test('chosenSide treats explicit "auto" as "use the geometric fallback"', () => {
assert.equal(chosenSide('left', 'right'), 'left');
assert.equal(chosenSide('auto', 'right'), 'right');
assert.equal(chosenSide(undefined, 'right'), 'right');
});
test('polylinePath emits M then L commands', () => {
assert.equal(polylinePath([[0, 0], [10, 0], [10, 10]]), 'M 0 0 L 10 0 L 10 10');
});
test('roundedPath degrades to a polyline for <3 points or radius<=0', () => {
assert.equal(roundedPath([[0, 0], [10, 0]], 10), 'M 0 0 L 10 0');
assert.equal(roundedPath([[0, 0], [10, 0], [10, 10]], 0), 'M 0 0 L 10 0 L 10 10');
});
test('roundedPath inserts a quadratic corner and never emits NaN', () => {
const d = roundedPath([[0, 0], [100, 0], [100, 100]], 10);
assert.match(d, /Q 100 0/); // corner pivots on the bend point
assert.doesNotMatch(d, /NaN/);
});
test('roundedPath clamps radius to half the shorter adjacent segment', () => {
// 6px segments with radius 10 → r clamps to 3; no overshoot / NaN.
const d = roundedPath([[0, 0], [6, 0], [6, 6]], 10);
assert.doesNotMatch(d, /NaN/);
assert.match(d, /^M 0 0/);
});
test('labelPoint: 2-point path is the midpoint lifted 10px, plus offsets', () => {
assert.deepEqual(labelPoint({}, [[0, 100], [100, 100]]), [50, 90]);
assert.deepEqual(labelPoint({ labelDx: 5, labelDy: -4 }, [[0, 100], [100, 100]]), [55, 86]);
});
test('labelPoint: labelSegment selects a segment and clamps to range', () => {
const pts = [[0, 0], [100, 0], [100, 100], [200, 100]];
// segment 0 → midpoint of pts[0],pts[1] = (50,0) lifted 10
assert.deepEqual(labelPoint({ labelSegment: 0 }, pts), [50, -10]);
// segment 99 clamps to the last segment
assert.deepEqual(labelPoint({ labelSegment: 99 }, pts), [150, 90]);
});
test('labelPoint: explicit labelAt wins outright', () => {
assert.deepEqual(labelPoint({ labelAt: [7, 8] }, [[0, 0], [100, 0]]), [7, 8]);
});
test('textUnits: ASCII=1, CJK=2, mixed sums, fullwidth supplementary=2', () => {
assert.equal(textUnits('abc'), 3);
assert.equal(textUnits('中文'), 4);
assert.equal(textUnits('a中'), 3);
assert.equal(textUnits(''), 0);
assert.equal(textUnits(null), 0);
assert.equal(textUnits('𠀀'), 2); // CJK Ext-B (supplementary plane)
assert.equal(textUnits('🚀'), 2); // emoji
assert.equal(textUnits('注入提示词'), 10); // issue #14 original label
assert.equal(textUnits('!@#012'), 12); // fullwidth punctuation + digits
});
test('textUnits follows wide and halfwidth East Asian presentation boundaries', () => {
assert.equal(textUnits('あカ'), 4); // Hiragana + Katakana are wide
assert.equal(textUnits('ㄅㆠ'), 4); // Bopomofo + extended Bopomofo are wide
assert.equal(textUnits('ㄱ'), 2); // Hangul compatibility letter is wide
assert.equal(textUnits('︐︙'), 4); // vertical punctuation forms are wide
assert.equal(textUnits('カタカナ'), 4); // halfwidth Katakana stays one unit per glyph
assert.equal(textUnits('ꥠ'), 2); // Hangul Jamo Extended-A is wide
});
test('textUnits counts emoji-presentation symbols in the BMP as wide', () => {
// These render at the same square advance as the supplementary-plane emoji,
// so counting them as one unit under-measures a label and lets it overflow
// its node while the layout receipt still reads clean.
assert.equal(textUnits('✅'), 2);
assert.equal(textUnits('⭐'), 2);
assert.equal(textUnits('⚡'), 2);
assert.equal(textUnits('⌛'), 2);
assert.equal(textUnits('⏰'), 2);
assert.equal(textUnits('⛔'), 2);
assert.equal(textUnits('❗'), 2);
assert.equal(textUnits('⬛'), 2);
assert.equal(textUnits('☕'), 2);
assert.equal(textUnits('♿'), 2);
assert.equal(textUnits('✅ Done'), 7);
// Narrow and ambiguous neighbours in the same blocks stay one unit.
assert.equal(textUnits('→'), 1); // rightwards arrow
assert.equal(textUnits('☎'), 1); // black telephone
assert.equal(textUnits('①'), 1); // circled digit one
// Unicode 16.0 moved these from Neutral to Wide.
assert.equal(textUnits('☰'), 2); // trigram for heaven
assert.equal(textUnits('☷'), 2); // trigram for earth
assert.equal(textUnits('⚊'), 2); // monogram for yang
assert.equal(textUnits('⚏'), 2); // digram for greater yin
// Hangul Jamo Extended-A stops at its last assigned jamo; the unassigned
// tail of the block defaults to Neutral.
assert.equal(textUnits('ꥼ'), 2);
assert.equal(textUnits('꥽'), 1);
});
test('textUnits measures a variation-selector sequence from the selector', () => {
// VS16 asks for emoji presentation: the pair renders as one square, so it
// must stay two units even though the base is now counted wide on its own.
assert.equal(textUnits('⭐️'), 2); // star
assert.equal(textUnits('✅️'), 2); // check mark button
assert.equal(textUnits('☕️'), 2); // hot beverage
assert.equal(textUnits('⚡️'), 2); // high voltage
// Same rule the other way: a narrow base forced to emoji presentation
// renders as a square and is two units, not one.
assert.equal(textUnits('✈️'), 2); // airplane
assert.equal(textUnits('❤️'), 2); // red heart
// VS15 asks for text presentation, which renders narrow.
assert.equal(textUnits('⭐︎'), 1);
assert.equal(textUnits('✈︎'), 1);
// The selector never adds width of its own, alone or in a run.
assert.equal(textUnits(''), 0);
assert.equal(textUnits('✅️ Done'), 7);
assert.equal(textUnits('⭐️⭐️'), 4);
});
test('semantic sigils cover every component and lifecycle kind without literal color', () => {
const kinds = [
'frontend', 'backend', 'database', 'cloud', 'security', 'messagebus', 'external',
'start', 'active', 'waiting', 'success', 'failure', 'neutral',
];
for (const kind of kinds) {
const sigil = renderSemanticSigil(kind, { x: 12, y: 18 });
assert.match(sigil, new RegExp(`data-semantic-sigil="${kind}"`), kind);
assert.match(sigil, /aria-hidden="true"/, kind);
assert.match(sigil, /class="semantic-sigil s-[a-z]+"/, kind);
assert.match(sigil, /transform="translate\(12 18\) scale\(0\.6875\)"/, kind);
assert.doesNotMatch(sigil, /#[0-9a-f]{3,8}|rgba?\(/i, kind);
}
});
test('unknown semantic sigils fail closed to a neutral role stamp', () => {
const sigil = renderSemanticSigil('vendor-logo', { x: 0, y: 0, size: 16 });
assert.match(sigil, /data-semantic-sigil="neutral"/);
assert.match(sigil, /class="semantic-sigil s-external"/);
assert.match(sigil, /scale\(1\)/);
});
test('suggestLabelObstacleFix includes rects and labelAt/labelDy hints', () => {
const labelRect = { x: 100, y: 180, width: 48, height: 14, label: '写入' };
const obstacle = { id: 'memtool', x: 30, y: 130, width: 230, height: 58 };
const hint = suggestLabelObstacleFix(labelRect, 124, 188, obstacle);
assert.match(hint, /label rect: \[100, 180, 48, 14\]/);
assert.match(hint, /component "memtool"/);
assert.match(hint, /Suggested fix: labelAt/);
assert.match(hint, /labelDy \+\d+/);
});
test('suggestComponentSeparation proposes nudged pos', () => {
const a = { id: 'api', x: 100, y: 200, width: 120, height: 60 };
const b = { id: 'db', x: 150, y: 200, width: 120, height: 60 };
const hint = suggestComponentSeparation(a, b, 8);
assert.match(hint, /move "db" pos to \[228, 200\]/);
});
test('applyTemplate preserves dollar sequences in titles', () => {
const template = `<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">
<title>[PROJECT NAME] Architecture Diagram</title>
<h1>[PROJECT NAME] Architecture</h1>
<p class="subtitle">[Subtitle description]</p>
<!-- ARCHIFY:GUIDED_VIEWS_DATA -->
<!-- ARCHIFY:SVG_SLOT_START --><svg></svg> <!-- ARCHIFY:SVG_SLOT_END -->
<!-- ARCHIFY:CARDS_SLOT_START --><div></div> <!-- ARCHIFY:CARDS_SLOT_END -->`;
const html = applyTemplate(template, {
title: 'Plan $$50 tier',
subtitle: 'test',
svg: '<svg/>',
cards: '',
});
assert.match(html, /Plan \$\$50 tier/);
assert.match(html, /<p class="subtitle">test<\/p>/);
});
test('applyTemplate omits the subtitle row when no subtitle is authored', () => {
const template = `<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">
<title>[PROJECT NAME] Architecture Diagram</title>
<h1>[PROJECT NAME] Architecture</h1>
<p class="subtitle">[Subtitle description]</p>
<!-- ARCHIFY:GUIDED_VIEWS_DATA -->
<!-- ARCHIFY:SVG_SLOT_START --><svg></svg> <!-- ARCHIFY:SVG_SLOT_END -->
<!-- ARCHIFY:CARDS_SLOT_START --><div></div> <!-- ARCHIFY:CARDS_SLOT_END -->`;
const html = applyTemplate(template, {
title: 'Focused title',
subtitle: ' ',
svg: '<svg/>',
cards: '',
});
assert.doesNotMatch(html, /class="subtitle"/);
assert.doesNotMatch(html, /Subtitle description/);
});
test('applyTemplate requires the new evidence slot only when evidence is present', () => {
const legacyTemplate = `<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">
<title>[PROJECT NAME] Architecture Diagram</title>
<h1>[PROJECT NAME] Architecture</h1>
<p class="subtitle">[Subtitle description]</p>
<!-- ARCHIFY:GUIDED_VIEWS_DATA -->
<!-- ARCHIFY:SVG_SLOT_START --><svg></svg> <!-- ARCHIFY:SVG_SLOT_END -->
<!-- ARCHIFY:CARDS_SLOT_START --><div></div> <!-- ARCHIFY:CARDS_SLOT_END -->`;
assert.doesNotThrow(() => applyTemplate(legacyTemplate, {
title: 'Legacy', subtitle: '', svg: '<svg/>', cards: '',
}));
assert.throws(() => applyTemplate(legacyTemplate, {
title: 'Evidence', subtitle: '', svg: '<svg/>', cards: '',
sourceEvidence: { verified: true },
}), /repository evidence requires placeholder/);
});