736 lines
36 KiB
JavaScript
736 lines
36 KiB
JavaScript
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, pathToFileURL } from 'node:url';
|
|
|
|
import { ChromeVisualBrowser, findChrome } from '../bin/visual-check.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const skillRoot = path.resolve(__dirname, '..');
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-semantic-radar-'));
|
|
const chromePath = process.env.ARCHIFY_CHROME ? findChrome() : null;
|
|
|
|
const CASES = {
|
|
architecture: 'web-app.architecture.json',
|
|
workflow: 'agent-tool-call.workflow.json',
|
|
sequence: 'cache-miss-request.sequence.json',
|
|
dataflow: 'product-analytics.dataflow.json',
|
|
lifecycle: 'agent-run.lifecycle.json',
|
|
};
|
|
|
|
function render(mode, example) {
|
|
const output = path.join(tmp, `${mode}.html`);
|
|
execFileSync(process.execPath, [
|
|
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
|
path.join(skillRoot, 'examples', example),
|
|
output,
|
|
]);
|
|
return fs.readFileSync(output, 'utf8');
|
|
}
|
|
|
|
function canonicalSvg(html) {
|
|
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
|
}
|
|
|
|
async function evaluate(browser, sessionId, expression, awaitPromise = false) {
|
|
const response = await browser.cdp.send('Runtime.evaluate', {
|
|
expression,
|
|
awaitPromise,
|
|
returnByValue: true,
|
|
}, sessionId);
|
|
if (response.exceptionDetails) {
|
|
throw new Error(response.exceptionDetails.exception?.description
|
|
|| response.exceptionDetails.text
|
|
|| 'Runtime.evaluate failed');
|
|
}
|
|
return response.result?.value;
|
|
}
|
|
|
|
async function loadArtifact(browser, artifactPath, { width = 1440, height = 900 } = {}) {
|
|
const sessionId = await browser.sessionPromise;
|
|
await browser.cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
width,
|
|
height,
|
|
deviceScaleFactor: 1,
|
|
mobile: false,
|
|
}, sessionId);
|
|
const loaded = browser.cdp.waitFor('Page.loadEventFired', sessionId);
|
|
const navigation = await browser.cdp.send('Page.navigate', {
|
|
url: pathToFileURL(artifactPath).href,
|
|
}, sessionId);
|
|
if (navigation.errorText) throw new Error(`Chrome navigation failed: ${navigation.errorText}`);
|
|
await loaded;
|
|
await evaluate(browser, sessionId, `(function () {
|
|
document.documentElement.setAttribute('data-motion', 'still');
|
|
var fontsReady = document.fonts && document.fonts.ready
|
|
? document.fonts.ready.catch(function () {})
|
|
: Promise.resolve();
|
|
return fontsReady.then(function () {
|
|
return new Promise(function (resolve) {
|
|
requestAnimationFrame(function () { requestAnimationFrame(resolve); });
|
|
});
|
|
});
|
|
})()`, true);
|
|
return sessionId;
|
|
}
|
|
|
|
async function radarRects(browser, sessionId, setup) {
|
|
return evaluate(browser, sessionId, `(function () {
|
|
${setup}
|
|
return new Promise(function (resolve) {
|
|
requestAnimationFrame(function () {
|
|
requestAnimationFrame(function () {
|
|
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
|
var controls = document.querySelector('.diagram-nav').getBoundingClientRect();
|
|
var passport = document.getElementById('focus-chip');
|
|
var passportRect = passport && !passport.hidden ? passport.getBoundingClientRect() : null;
|
|
resolve({
|
|
radar: { left: radar.left, top: radar.top, right: radar.right, bottom: radar.bottom },
|
|
controls: { left: controls.left, top: controls.top, right: controls.right, bottom: controls.bottom },
|
|
passport: passportRect ? {
|
|
left: passportRect.left,
|
|
top: passportRect.top,
|
|
right: passportRect.right,
|
|
bottom: passportRect.bottom
|
|
} : null
|
|
});
|
|
});
|
|
});
|
|
});
|
|
})()`, true);
|
|
}
|
|
|
|
async function dragMouse(browser, sessionId, from, to) {
|
|
await browser.cdp.send('Input.dispatchMouseEvent', {
|
|
type: 'mousePressed',
|
|
x: from.x,
|
|
y: from.y,
|
|
button: 'left',
|
|
buttons: 1,
|
|
clickCount: 1,
|
|
}, sessionId);
|
|
await browser.cdp.send('Input.dispatchMouseEvent', {
|
|
type: 'mouseMoved',
|
|
x: to.x,
|
|
y: to.y,
|
|
button: 'left',
|
|
buttons: 1,
|
|
}, sessionId);
|
|
await browser.cdp.send('Input.dispatchMouseEvent', {
|
|
type: 'mouseReleased',
|
|
x: to.x,
|
|
y: to.y,
|
|
button: 'left',
|
|
buttons: 0,
|
|
clickCount: 1,
|
|
}, sessionId);
|
|
await evaluate(browser, sessionId, `new Promise(function (resolve) {
|
|
requestAnimationFrame(function () { requestAnimationFrame(resolve); });
|
|
})`, true);
|
|
}
|
|
|
|
function overlaps(a, b, gap = 0) {
|
|
return a.left < b.right + gap
|
|
&& a.right > b.left - gap
|
|
&& a.top < b.bottom + gap
|
|
&& a.bottom > b.top - gap;
|
|
}
|
|
|
|
test('all typed renderers inherit one viewer-only Semantic Radar', () => {
|
|
for (const [mode, example] of Object.entries(CASES)) {
|
|
const html = render(mode, example);
|
|
assert.match(html, /id="overview-map" hidden role="region" aria-labelledby="overview-map-title"/, mode);
|
|
assert.match(html, /id="overview-map-surface" tabindex="0" role="group"/, mode);
|
|
assert.match(html, /id="overview-map-expand"[^>]+aria-label="Open full semantic radar"/, mode);
|
|
assert.match(html, /id="overview-map-feedback" role="status" aria-live="polite" hidden/, mode);
|
|
assert.match(html, /id="btn-overview-map"[^>]+aria-label="Open semantic radar"[^>]+aria-expanded="false"[^>]+aria-controls="overview-map"/, mode);
|
|
assert.match(html, /Archify\.radar = \(function \(\)/, mode);
|
|
assert.match(html, /document\.createElementNS\(namespace, 'svg'\)/, mode);
|
|
assert.match(html, /mapSvg\.setAttribute\('aria-label', viewerText\('viewer\.radar\.nodes'\)\)/, mode);
|
|
assert.match(html, /diagram\.querySelectorAll\('\[data-node-id\]'\)/, mode);
|
|
assert.equal((html.match(/<svg\b/g) || []).length, 1, `${mode} keeps one static canonical SVG`);
|
|
assert.doesNotMatch(canonicalSvg(html), /overview-map|Semantic radar|data-radar-node-id/, mode);
|
|
}
|
|
});
|
|
|
|
test('Semantic Radar derives semantic node bounds and focuses stable IDs', () => {
|
|
const html = render('workflow', CASES.workflow);
|
|
assert.match(html, /box = node\.getBBox\(\)/);
|
|
assert.match(html, /rect\.setAttribute\('data-radar-node-id', id\)/);
|
|
assert.match(html, /rect\.setAttribute\('data-kind', node\.getAttribute\('data-node-kind'\) \|\| 'neutral'\)/);
|
|
assert.match(html, /rect\.setAttribute\('aria-label', viewerText\('viewer\.radar\.focus'/);
|
|
assert.match(html, /Archify\.focus\.set\(id, \{ toggle: false \}\)/);
|
|
assert.match(html, /Archify\.view\.reveal\(\[id\], \{ includeNeighbors: true, reason: 'radar' \}\)/);
|
|
assert.match(html, /function bringNodeIntoWindow\(node\)/);
|
|
assert.match(html, /window\.scrollY \+ rect\.top \+ rect\.height \/ 2 - window\.innerHeight \/ 2/);
|
|
assert.match(html, /data-radar-active/);
|
|
});
|
|
|
|
test('Semantic Radar tracks desktop camera and mobile contained scroll', () => {
|
|
const html = render('sequence', CASES.sequence);
|
|
assert.match(html, /function logicalViewport\(\)/);
|
|
assert.match(html, /x = viewBox\.x \+ container\.scrollLeft \/ metrics\.scale/);
|
|
assert.match(html, /x = viewBox\.x \+ \(\(-state\.x \/ state\.scale\) - metrics\.offsetX\) \/ metrics\.scale/);
|
|
assert.match(html, /viewport\.setAttribute\('width', String\(visible\.width\)\)/);
|
|
assert.match(html, /viewerText\('viewer\.radar\.viewport\.width'/);
|
|
assert.match(html, /function centerAt\(logicalX, logicalY, options\)/);
|
|
assert.match(html, /minimumScale: 1\.5, instant: true/);
|
|
assert.match(html, /container\.scrollTo\(\{ left: mobileTarget, behavior: options\.instant \? 'auto' : 'smooth' \}\)/);
|
|
assert.match(html, /data-wide-diagram="true"\] \.overview-map/);
|
|
assert.match(html, /function updateDocking\(\)/);
|
|
assert.match(html, /chip\.style\.top = Math\.round\(top\) \+ 'px';[\s\S]+Archify\.radar\.sync\(\)/);
|
|
assert.match(html, /var navigation = container\.querySelector\('\.diagram-nav'\)/);
|
|
assert.match(html, /if \(controlRect\) bottom = Math\.min\(bottom, controlRect\.top - placementGap\)/);
|
|
assert.match(html, /hardBlockers: \[lensRect, controlRect, legendRect\]\.filter\(Boolean\)/);
|
|
assert.match(html, /function cornerCandidates\(context\)/);
|
|
assert.match(html, /function nearbyCandidates\(context, reference\)/);
|
|
assert.match(html, /nearbyCandidates\(context, reference\)\.concat\(cornerCandidates\(context\)\)/);
|
|
assert.match(html, /var placementOptions = \{ softWeight: manualPosition \? 0 : 100 \}/);
|
|
assert.match(html, /manualPosition && positionIsValid\(manualPosition, context\)/);
|
|
assert.match(html, /panelHead\.addEventListener\('pointerdown', beginPanelDrag\)/);
|
|
assert.match(html, /surface\.addEventListener\('pointerdown',[\s\S]+viewportDrag = \{ pointerId: event\.pointerId \}/);
|
|
assert.match(html, /target\.closest\('\[data-node-id\], \[data-relationship-hit-key\], \.overview-map'\)/);
|
|
assert.match(html, /--archify-radar-top/);
|
|
assert.match(html, /\.overview-map\[data-docked="true"\]/);
|
|
});
|
|
|
|
test('Semantic Radar keeps redundant accessible navigation and clean exports', () => {
|
|
const html = render('architecture', CASES.architecture);
|
|
assert.match(html, /Semantic radar \(M\)/);
|
|
assert.match(html, /e\.key === 'm' \|\| e\.key === 'M'/);
|
|
assert.match(html, /e\.key === 'Escape' && Archify\.radar\.isOpen\(\)/);
|
|
assert.match(html, /event\.key === 'ArrowLeft'[\s\S]+event\.key === 'ArrowRight'[\s\S]+event\.key === 'ArrowUp'[\s\S]+event\.key === 'ArrowDown'/);
|
|
assert.match(html, /node && \(event\.key === 'Enter' \|\| event\.key === ' '\)/);
|
|
assert.match(html, /\.overview-map-viewport \{[\s\S]*?pointer-events: none;/);
|
|
assert.match(html, /html\[data-embed="true"\] \.overview-map/);
|
|
assert.match(html, /class="overview-map no-print"/);
|
|
assert.match(html, /The radar is built at runtime so the checked artifact still contains[\s\S]+one canonical SVG block/);
|
|
assert.doesNotMatch(canonicalSvg(html), /overview-map-node|overview-map-viewport/);
|
|
});
|
|
|
|
test('Semantic Radar stays above the measured MAP control strip', {
|
|
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
|
}, async () => {
|
|
const artifact = path.join(tmp, 'radar-control-clearance.html');
|
|
execFileSync(process.execPath, [
|
|
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
|
path.join(skillRoot, 'examples', CASES.architecture),
|
|
artifact,
|
|
]);
|
|
const browser = new ChromeVisualBrowser(chromePath);
|
|
try {
|
|
const sessionId = await loadArtifact(browser, artifact, { width: 1440, height: 900 });
|
|
const rects = await radarRects(browser, sessionId, `
|
|
var container = document.querySelector('.diagram-container');
|
|
window.scrollTo(0, Math.max(0, container.offsetTop + container.offsetHeight - window.innerHeight + 8));
|
|
Archify.radar.open();
|
|
`);
|
|
const controlGap = rects.controls.top - rects.radar.bottom;
|
|
assert.ok(controlGap >= 15, JSON.stringify({ ...rects, controlGap }, null, 2));
|
|
assert.ok(
|
|
rects.radar.left < rects.controls.left,
|
|
`automatic placement should prefer the lower-left corner: ${JSON.stringify(rects, null, 2)}`,
|
|
);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
test('Semantic Radar avoids an expanded mobile Passport without hiding a collision', {
|
|
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
|
}, async () => {
|
|
const artifact = path.join(tmp, 'radar-mobile-passport.html');
|
|
execFileSync(process.execPath, [
|
|
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
|
path.join(skillRoot, 'examples', CASES.architecture),
|
|
artifact,
|
|
]);
|
|
const browser = new ChromeVisualBrowser(chromePath);
|
|
try {
|
|
const sessionId = await loadArtifact(browser, artifact, { width: 390, height: 600 });
|
|
const state = await evaluate(browser, sessionId, `(function () {
|
|
var container = document.querySelector('.diagram-container');
|
|
window.scrollTo(0, Math.max(0, container.offsetTop));
|
|
Archify.focus.set('lb', { toggle: false });
|
|
document.getElementById('btn-focus-relations').click();
|
|
Archify.radar.open();
|
|
return new Promise(function (resolve) {
|
|
setTimeout(function () {
|
|
var radar = document.getElementById('overview-map');
|
|
var radarRect = radar.getBoundingClientRect();
|
|
var passportRect = document.getElementById('focus-chip').getBoundingClientRect();
|
|
var containerRect = document.querySelector('.diagram-container').getBoundingClientRect();
|
|
var controlsRect = document.querySelector('.diagram-nav').getBoundingClientRect();
|
|
var legendRect = document.querySelector('[data-legend]').getBoundingClientRect();
|
|
resolve({
|
|
radar: { left: radarRect.left, top: radarRect.top, right: radarRect.right, bottom: radarRect.bottom },
|
|
passport: { left: passportRect.left, top: passportRect.top, right: passportRect.right, bottom: passportRect.bottom },
|
|
container: { left: containerRect.left, top: containerRect.top, right: containerRect.right, bottom: containerRect.bottom },
|
|
controls: { left: controlsRect.left, top: controlsRect.top, right: controlsRect.right, bottom: controlsRect.bottom },
|
|
legend: { left: legendRect.left, top: legendRect.top, right: legendRect.right, bottom: legendRect.bottom },
|
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
invalid: radar.getAttribute('data-placement-invalid'),
|
|
compact: radar.getAttribute('data-compact'),
|
|
unavailable: radar.getAttribute('data-placement-unavailable')
|
|
});
|
|
}, 180);
|
|
});
|
|
})()`, true);
|
|
assert.equal(overlaps(state.radar, state.passport, 10), false, JSON.stringify(state, null, 2));
|
|
assert.notEqual(state.invalid, 'true', JSON.stringify(state, null, 2));
|
|
assert.equal(state.compact, 'true', JSON.stringify(state, null, 2));
|
|
|
|
const expanded = await evaluate(browser, sessionId, `(function () {
|
|
document.getElementById('overview-map-expand').click();
|
|
return new Promise(function (resolve) {
|
|
setTimeout(function () {
|
|
var radar = document.getElementById('overview-map');
|
|
var rect = radar.getBoundingClientRect();
|
|
var passport = document.getElementById('focus-chip');
|
|
resolve({
|
|
compact: radar.getAttribute('data-compact'),
|
|
height: rect.height,
|
|
surfaceVisible: getComputedStyle(document.getElementById('overview-map-surface')).display !== 'none',
|
|
passportYielded: passport.getAttribute('data-radar-yielded'),
|
|
passportVisible: getComputedStyle(passport).display !== 'none'
|
|
});
|
|
}, 120);
|
|
});
|
|
})()`, true);
|
|
assert.equal(expanded.compact, null, JSON.stringify(expanded, null, 2));
|
|
assert.equal(expanded.surfaceVisible, true, JSON.stringify(expanded, null, 2));
|
|
assert.equal(expanded.passportYielded, 'true', JSON.stringify(expanded, null, 2));
|
|
assert.equal(expanded.passportVisible, false, JSON.stringify(expanded, null, 2));
|
|
assert.ok(expanded.height > state.radar.bottom - state.radar.top, JSON.stringify({ state, expanded }, null, 2));
|
|
|
|
const closed = await evaluate(browser, sessionId, `(function () {
|
|
document.getElementById('overview-map-close').click();
|
|
var passport = document.getElementById('focus-chip');
|
|
return {
|
|
radarHidden: document.getElementById('overview-map').hidden,
|
|
passportYielded: passport.getAttribute('data-radar-yielded'),
|
|
passportVisible: getComputedStyle(passport).display !== 'none'
|
|
};
|
|
})()`);
|
|
assert.equal(closed.radarHidden, true, JSON.stringify(closed, null, 2));
|
|
assert.equal(closed.passportYielded, null, JSON.stringify(closed, null, 2));
|
|
assert.equal(closed.passportVisible, true, JSON.stringify(closed, null, 2));
|
|
|
|
for (const original of [null, 'false', 'true']) {
|
|
const restored = await evaluate(browser, sessionId, `(function () {
|
|
var passport = document.getElementById('focus-chip');
|
|
var original = ${JSON.stringify(original)};
|
|
if (original === null) passport.removeAttribute('aria-hidden');
|
|
else passport.setAttribute('aria-hidden', original);
|
|
Archify.radar.open();
|
|
document.getElementById('overview-map-expand').click();
|
|
var during = { yielded: passport.getAttribute('data-radar-yielded'), aria: passport.getAttribute('aria-hidden') };
|
|
Archify.radar.close();
|
|
return { during: during, yielded: passport.getAttribute('data-radar-yielded'), aria: passport.getAttribute('aria-hidden') };
|
|
})()`);
|
|
assert.deepEqual(restored, { during: { yielded: 'true', aria: 'true' }, yielded: null, aria: original });
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
test('Semantic Radar reports a consistent unavailable state and recovers when space returns', {
|
|
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
|
}, async () => {
|
|
const artifact = path.join(tmp, 'radar-unavailable.html');
|
|
execFileSync(process.execPath, [
|
|
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
|
path.join(skillRoot, 'examples', CASES.architecture),
|
|
artifact,
|
|
]);
|
|
const browser = new ChromeVisualBrowser(chromePath);
|
|
try {
|
|
const sessionId = await loadArtifact(browser, artifact, { width: 390, height: 300 });
|
|
const unavailable = await evaluate(browser, sessionId, `(function () {
|
|
var container = document.querySelector('.diagram-container');
|
|
window.scrollTo(0, Math.max(0, container.offsetTop));
|
|
Archify.focus.set('lb', { toggle: false });
|
|
document.getElementById('btn-focus-relations').click();
|
|
Archify.radar.open();
|
|
return new Promise(function (resolve) {
|
|
setTimeout(function () {
|
|
var panel = document.getElementById('overview-map');
|
|
var trigger = document.getElementById('btn-overview-map');
|
|
var feedback = document.getElementById('overview-map-feedback');
|
|
resolve({
|
|
requested: Archify.radar.isOpen(),
|
|
panelHidden: panel.hidden,
|
|
expanded: trigger.getAttribute('aria-expanded'),
|
|
limited: trigger.getAttribute('data-radar-space-limited'),
|
|
feedbackHidden: feedback.hidden,
|
|
feedback: feedback.textContent.trim()
|
|
});
|
|
}, 260);
|
|
});
|
|
})()`, true);
|
|
assert.equal(unavailable.requested, true);
|
|
assert.equal(unavailable.panelHidden, true, JSON.stringify(unavailable, null, 2));
|
|
assert.equal(unavailable.expanded, 'false', JSON.stringify(unavailable, null, 2));
|
|
assert.equal(unavailable.limited, 'true', JSON.stringify(unavailable, null, 2));
|
|
assert.equal(unavailable.feedbackHidden, false, JSON.stringify(unavailable, null, 2));
|
|
assert.match(unavailable.feedback, /space/i);
|
|
|
|
await browser.cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
width: 390,
|
|
height: 600,
|
|
deviceScaleFactor: 1,
|
|
mobile: false,
|
|
}, sessionId);
|
|
const recovered = await evaluate(browser, sessionId, `new Promise(function (resolve) {
|
|
setTimeout(function () {
|
|
var panel = document.getElementById('overview-map');
|
|
var trigger = document.getElementById('btn-overview-map');
|
|
var feedback = document.getElementById('overview-map-feedback');
|
|
resolve({
|
|
requested: Archify.radar.isOpen(),
|
|
panelHidden: panel.hidden,
|
|
expanded: trigger.getAttribute('aria-expanded'),
|
|
feedbackHidden: feedback.hidden
|
|
});
|
|
}, 260);
|
|
})`, true);
|
|
assert.equal(recovered.requested, true);
|
|
assert.equal(recovered.panelHidden, false, JSON.stringify(recovered, null, 2));
|
|
assert.equal(recovered.expanded, 'true', JSON.stringify(recovered, null, 2));
|
|
assert.equal(recovered.feedbackHidden, true, JSON.stringify(recovered, null, 2));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
test('Semantic Radar automatically avoids a tall Semantic Passport', {
|
|
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
|
}, async () => {
|
|
const input = path.join(tmp, 'tall-passport.architecture.json');
|
|
const artifact = path.join(tmp, 'tall-passport.html');
|
|
const peers = Array.from({ length: 12 }, (_, index) => ({
|
|
id: `peer-${index + 1}`,
|
|
type: index % 2 ? 'backend' : 'database',
|
|
label: `Peer ${index + 1}`,
|
|
sublabel: 'Connected system',
|
|
pos: [80, 40 + index * 90],
|
|
size: [130, 60],
|
|
}));
|
|
fs.writeFileSync(input, JSON.stringify({
|
|
schema_version: 1,
|
|
diagram_type: 'architecture',
|
|
meta: { title: 'Tall Passport Radar Regression', output: artifact },
|
|
components: [
|
|
...peers,
|
|
{ id: 'hub', type: 'security', label: 'Relationship Hub', sublabel: 'Many authored links', pos: [900, 500], size: [150, 70] },
|
|
],
|
|
boundaries: [],
|
|
connections: peers.map((peer, index) => ({
|
|
id: `hub-to-${peer.id}`,
|
|
from: 'hub',
|
|
to: peer.id,
|
|
fromSide: 'left',
|
|
toSide: 'right',
|
|
via: [[840, 535], [840, peer.pos[1] + 30]],
|
|
})),
|
|
cards: [],
|
|
}, null, 2));
|
|
execFileSync(process.execPath, [
|
|
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
|
input,
|
|
artifact,
|
|
]);
|
|
const browser = new ChromeVisualBrowser(chromePath);
|
|
try {
|
|
const sessionId = await loadArtifact(browser, artifact, { width: 1200, height: 700 });
|
|
const rects = await radarRects(browser, sessionId, `
|
|
var container = document.querySelector('.diagram-container');
|
|
window.scrollTo(0, Math.max(0, container.offsetTop));
|
|
Archify.focus.set('hub', { toggle: false });
|
|
Archify.radar.open();
|
|
`);
|
|
assert.ok(rects.passport, JSON.stringify(rects, null, 2));
|
|
assert.equal(overlaps(rects.radar, rects.passport, 10), false, JSON.stringify(rects, null, 2));
|
|
|
|
const dragGeometry = await evaluate(browser, sessionId, `(function () {
|
|
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
|
var head = document.querySelector('.overview-map-head').getBoundingClientRect();
|
|
var passport = document.getElementById('focus-chip').getBoundingClientRect();
|
|
var active = document.querySelector('[data-focus-selected]');
|
|
var nearestLeft = passport.right + 16;
|
|
active.getBoundingClientRect = function () {
|
|
return {
|
|
left: nearestLeft,
|
|
top: radar.top,
|
|
right: nearestLeft + radar.width,
|
|
bottom: radar.top + radar.height,
|
|
width: radar.width,
|
|
height: radar.height
|
|
};
|
|
};
|
|
return {
|
|
radar: { left: radar.left, top: radar.top },
|
|
head: { left: head.left, top: head.top, height: head.height },
|
|
requested: { left: passport.right + 8, top: radar.top },
|
|
nearest: { left: nearestLeft, top: radar.top }
|
|
};
|
|
})()`);
|
|
await dragMouse(browser, sessionId, {
|
|
x: dragGeometry.head.left + 48,
|
|
y: dragGeometry.head.top + dragGeometry.head.height / 2,
|
|
}, {
|
|
x: dragGeometry.head.left + 48 + dragGeometry.requested.left - dragGeometry.radar.left,
|
|
y: dragGeometry.head.top + dragGeometry.head.height / 2 + dragGeometry.requested.top - dragGeometry.radar.top,
|
|
});
|
|
const snappedRects = await radarRects(browser, sessionId, '');
|
|
assert.equal(overlaps(snappedRects.radar, snappedRects.passport, 10), false, JSON.stringify(snappedRects, null, 2));
|
|
assert.ok(Math.abs(snappedRects.radar.left - dragGeometry.nearest.left) <= 2, JSON.stringify({ dragGeometry, snappedRects }, null, 2));
|
|
assert.ok(Math.abs(snappedRects.radar.top - dragGeometry.nearest.top) <= 2, JSON.stringify({ dragGeometry, snappedRects }, null, 2));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
test('Semantic Radar titlebar drag persists while surface drag still pans the diagram', {
|
|
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
|
}, async () => {
|
|
const artifact = path.join(tmp, 'radar-dragging.html');
|
|
execFileSync(process.execPath, [
|
|
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
|
path.join(skillRoot, 'examples', CASES.architecture),
|
|
artifact,
|
|
]);
|
|
const browser = new ChromeVisualBrowser(chromePath);
|
|
try {
|
|
const sessionId = await loadArtifact(browser, artifact, { width: 1440, height: 900 });
|
|
const geometry = await evaluate(browser, sessionId, `(function () {
|
|
var container = document.querySelector('.diagram-container');
|
|
window.scrollTo(0, Math.max(0, container.offsetTop + container.offsetHeight - window.innerHeight + 8));
|
|
Archify.radar.open();
|
|
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
|
var head = document.querySelector('.overview-map-head').getBoundingClientRect();
|
|
var containerRect = container.getBoundingClientRect();
|
|
return {
|
|
radar: { left: radar.left, top: radar.top, width: radar.width, height: radar.height },
|
|
head: { left: head.left, top: head.top, width: head.width, height: head.height },
|
|
state: Archify.view.state(),
|
|
target: {
|
|
left: Math.max(24, containerRect.left + 360),
|
|
top: Math.max(24, containerRect.top + 20)
|
|
}
|
|
};
|
|
})()`);
|
|
const titleStart = {
|
|
x: geometry.head.left + 48,
|
|
y: geometry.head.top + geometry.head.height / 2,
|
|
};
|
|
const titleTarget = {
|
|
x: titleStart.x + geometry.target.left - geometry.radar.left,
|
|
y: titleStart.y + geometry.target.top - geometry.radar.top,
|
|
};
|
|
await dragMouse(browser, sessionId, titleStart, titleTarget);
|
|
|
|
const manuallyPlaced = await evaluate(browser, sessionId, `(function () {
|
|
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
|
return { left: radar.left, top: radar.top, state: Archify.view.state() };
|
|
})()`);
|
|
assert.ok(Math.abs(manuallyPlaced.left - geometry.target.left) <= 2, JSON.stringify({ geometry, manuallyPlaced }, null, 2));
|
|
assert.ok(Math.abs(manuallyPlaced.top - geometry.target.top) <= 2, JSON.stringify({ geometry, manuallyPlaced }, null, 2));
|
|
assert.deepEqual(manuallyPlaced.state, geometry.state);
|
|
|
|
// Capture the transient drag and cancellation in the same page operation.
|
|
for (const cancel of ['pointercancel', 'Escape']) {
|
|
const cancelled = await evaluate(browser, sessionId, `(function () {
|
|
var panel = document.getElementById('overview-map');
|
|
var head = panel.querySelector('.overview-map-head');
|
|
var rect = panel.getBoundingClientRect();
|
|
var start = { left: rect.left, top: rect.top };
|
|
var h = head.getBoundingClientRect();
|
|
function pointer(type, offset) {
|
|
head.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 71,
|
|
button: 0, clientX: h.left + 48 + offset, clientY: h.top + h.height / 2 }));
|
|
}
|
|
pointer('pointerdown', 0);
|
|
pointer('pointermove', 80);
|
|
var moved = panel.getBoundingClientRect().left !== start.left;
|
|
var dragging = panel.getAttribute('data-panel-dragging');
|
|
if (${JSON.stringify(cancel)} === 'Escape') document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
else pointer('pointercancel', 80);
|
|
var end = panel.getBoundingClientRect();
|
|
return { start: start, end: { left: end.left, top: end.top }, moved: moved, dragging: dragging,
|
|
afterDragging: panel.getAttribute('data-panel-dragging'), open: Archify.radar.isOpen(), state: Archify.view.state() };
|
|
})()`);
|
|
assert.equal(cancelled.moved, true, cancel);
|
|
assert.equal(cancelled.dragging, 'true', cancel);
|
|
assert.equal(cancelled.afterDragging, null, cancel);
|
|
assert.equal(cancelled.open, true, cancel);
|
|
assert.deepEqual(cancelled.end, cancelled.start, cancel);
|
|
assert.deepEqual(cancelled.state, manuallyPlaced.state, cancel);
|
|
}
|
|
const reopened = await evaluate(browser, sessionId, `(function () {
|
|
Archify.radar.close();
|
|
Archify.radar.open();
|
|
Archify.radar.open();
|
|
var rect = document.getElementById('overview-map').getBoundingClientRect();
|
|
return { left: rect.left, top: rect.top, roots: document.querySelectorAll('#overview-map-surface > svg').length,
|
|
count: Archify.radar.count(), nodes: document.querySelectorAll('[data-radar-node-id]').length };
|
|
})()`);
|
|
assert.equal(reopened.left, manuallyPlaced.left);
|
|
assert.equal(reopened.top, manuallyPlaced.top);
|
|
assert.equal(reopened.roots, 1);
|
|
assert.ok(reopened.count > 0);
|
|
assert.equal(reopened.count, reopened.nodes);
|
|
|
|
const surfaceState = await evaluate(browser, sessionId, `(function () {
|
|
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
|
var surface = document.getElementById('overview-map-surface').getBoundingClientRect();
|
|
return {
|
|
radar: { left: radar.left, top: radar.top },
|
|
state: Archify.view.state(),
|
|
start: { x: surface.left + 8, y: surface.top + 8 },
|
|
end: { x: surface.right - 8, y: surface.bottom - 8 }
|
|
};
|
|
})()`);
|
|
await dragMouse(browser, sessionId, surfaceState.start, surfaceState.end);
|
|
const afterSurfaceDrag = await evaluate(browser, sessionId, `(function () {
|
|
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
|
return {
|
|
radar: { left: radar.left, top: radar.top },
|
|
state: Archify.view.state()
|
|
};
|
|
})()`);
|
|
assert.deepEqual(afterSurfaceDrag.radar, surfaceState.radar);
|
|
assert.notDeepEqual(afterSurfaceDrag.state, surfaceState.state);
|
|
|
|
await browser.cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
width: 640,
|
|
height: 700,
|
|
deviceScaleFactor: 1,
|
|
mobile: false,
|
|
}, sessionId);
|
|
const afterResize = await evaluate(browser, sessionId, `new Promise(function (resolve) {
|
|
setTimeout(function () {
|
|
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
|
var controls = document.querySelector('.diagram-nav').getBoundingClientRect();
|
|
var container = document.querySelector('.diagram-container').getBoundingClientRect();
|
|
resolve({
|
|
radar: { left: radar.left, top: radar.top, right: radar.right, bottom: radar.bottom },
|
|
controls: { left: controls.left, top: controls.top, right: controls.right, bottom: controls.bottom },
|
|
container: { left: container.left, top: container.top, right: container.right, bottom: container.bottom },
|
|
viewport: { width: window.innerWidth, height: window.innerHeight }
|
|
});
|
|
}, 120);
|
|
})`, true);
|
|
assert.ok(afterResize.radar.left >= Math.max(0, afterResize.container.left), JSON.stringify(afterResize, null, 2));
|
|
assert.ok(afterResize.radar.right <= Math.min(afterResize.viewport.width, afterResize.container.right), JSON.stringify(afterResize, null, 2));
|
|
assert.equal(overlaps(afterResize.radar, afterResize.controls, 10), false, JSON.stringify(afterResize, null, 2));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
test('closing a pending Radar request prevents retry and reflow from reopening it', {
|
|
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
|
}, async () => {
|
|
render('architecture', CASES.architecture);
|
|
const browser = new ChromeVisualBrowser(chromePath);
|
|
try {
|
|
const sessionId = await loadArtifact(browser, path.join(tmp, 'architecture.html'), { width: 390, height: 300 });
|
|
const transition = await evaluate(browser, sessionId, `(function () {
|
|
var container = document.querySelector('.diagram-container');
|
|
window.scrollTo(0, container.offsetTop);
|
|
Archify.focus.set('lb', { toggle: false });
|
|
document.getElementById('btn-focus-relations').click();
|
|
var opened = Archify.radar.open();
|
|
var panel = document.getElementById('overview-map');
|
|
var waiting = { result: opened, requested: Archify.radar.isOpen(), hidden: panel.hidden };
|
|
var closed = Archify.radar.close({ restoreFocus: true });
|
|
return { waiting: waiting, closed: closed, requested: Archify.radar.isOpen(),
|
|
focused: document.activeElement.id, feedbackHidden: document.getElementById('overview-map-feedback').hidden };
|
|
})()`);
|
|
assert.deepEqual(transition, { waiting: { result: true, requested: true, hidden: true },
|
|
closed: false, requested: false, focused: 'btn-overview-map', feedbackHidden: true });
|
|
await browser.cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
width: 390, height: 600, deviceScaleFactor: 1, mobile: false,
|
|
}, sessionId);
|
|
// Observe the whole original retry window, not just a single settled frame.
|
|
const after = await evaluate(browser, sessionId, `new Promise(function (resolve) {
|
|
var reopened = false;
|
|
var start = performance.now();
|
|
function sample() {
|
|
var panel = document.getElementById('overview-map');
|
|
reopened = reopened || Archify.radar.isOpen() || !panel.hidden;
|
|
if (performance.now() - start < 500) return requestAnimationFrame(sample);
|
|
resolve({ reopened: reopened, expanded: document.getElementById('btn-overview-map').getAttribute('aria-expanded'),
|
|
feedbackHidden: document.getElementById('overview-map-feedback').hidden });
|
|
}
|
|
requestAnimationFrame(sample);
|
|
})`, true);
|
|
assert.deepEqual(after, { reopened: false, expanded: 'false', feedbackHidden: true });
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
test('Radar reflects camera viewport, status and Focus/Story activity through normal callers', {
|
|
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
|
}, async () => {
|
|
render('architecture', CASES.architecture);
|
|
const browser = new ChromeVisualBrowser(chromePath);
|
|
try {
|
|
const sessionId = await loadArtifact(browser, path.join(tmp, 'architecture.html'));
|
|
await browser.cdp.send('Emulation.setEmulatedMedia', { features: [
|
|
{ name: 'prefers-reduced-motion', value: 'reduce' },
|
|
] }, sessionId);
|
|
async function observe(action) {
|
|
return evaluate(browser, sessionId, `(async () => {
|
|
${action}
|
|
let previous = '', equal = 0;
|
|
for (let frame = 0; frame < 240; frame += 1) {
|
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
const viewport = document.querySelector('.overview-map-viewport');
|
|
const actual = ['x','y','width','height'].map(name => Number(viewport.getAttribute(name)));
|
|
const logical = Archify.view.logicalViewport();
|
|
const expected = [logical.x,logical.y,logical.width,logical.height];
|
|
const active = Array.from(document.querySelectorAll('[data-radar-active]'), node => node.getAttribute('data-radar-node-id')).sort();
|
|
const status = document.getElementById('overview-map-status').textContent;
|
|
const state = { actual, expected, active, status, count: Archify.radar.count(), scale: logical.scale,
|
|
beat: Archify.guidedViews.beat()?.nodeId || null };
|
|
const value = JSON.stringify(state);
|
|
equal = value === previous ? equal + 1 : 0; previous = value;
|
|
if (equal >= 8) return state;
|
|
}
|
|
throw new Error('Radar observations did not settle');
|
|
})()`, true);
|
|
}
|
|
const initial = await observe(`window.scrollTo(0, document.querySelector('.diagram-container').offsetTop); Archify.radar.open();`);
|
|
assert.deepEqual(initial.actual, initial.expected);
|
|
assert.equal(initial.status, initial.count + ' nodes · full map');
|
|
const zoomed = await observe(`document.querySelector('[data-view="in"]').click();`);
|
|
assert.deepEqual(zoomed.actual, zoomed.expected);
|
|
assert.notDeepEqual(zoomed.actual, initial.actual);
|
|
assert.equal(zoomed.status, zoomed.count + ' nodes · ' + Math.round(zoomed.scale * 100) + '% viewport');
|
|
assert.notEqual(zoomed.status, initial.status);
|
|
assert.deepEqual((await observe(`Archify.focus.set('lb', { toggle:false });`)).active, ['lb']);
|
|
assert.deepEqual((await observe(`Archify.focus.set('db', { toggle:false });`)).active, ['db']);
|
|
assert.deepEqual((await observe(`Archify.focus.clear();`)).active, []);
|
|
await observe(`Archify.guidedViews.activate('request-path');`);
|
|
const first = await observe(`document.querySelector('[data-story-index="0"]').click(); Archify.focus.clear({ updateUrl:false });`);
|
|
assert.ok(first.beat);
|
|
assert.deepEqual(first.active, [first.beat]);
|
|
const second = await observe(`document.querySelector('[data-story-index="1"]').click(); Archify.focus.clear({ updateUrl:false });`);
|
|
assert.ok(second.beat);
|
|
assert.notEqual(second.beat, first.beat);
|
|
assert.deepEqual(second.active, [second.beat]);
|
|
assert.deepEqual((await observe(`Archify.guidedViews.showAll();`)).active, []);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|