diff --git a/diagrams/strategy-target.architecture.json b/diagrams/strategy-target.architecture.json index 93d5b3f..467b406 100644 --- a/diagrams/strategy-target.architecture.json +++ b/diagrams/strategy-target.architecture.json @@ -23,14 +23,14 @@ ], "connections": [ - {"id":"govern-scope","from":"agent","to":"scope","label":"","variant":"security","fromSide":"right","toSide":"bottom","via":[[750,495],[750,292.5],[600,292.5]]}, - {"id":"govern-architecture","from":"agent","to":"architecture","label":"","variant":"security","fromSide":"right","toSide":"bottom","via":[[765,495],[765,345],[870,345]]}, - {"id":"govern-ui-ux","from":"agent","to":"ui-ux","label":"","variant":"security","fromSide":"right","toSide":"left"}, - {"id":"govern-planning","from":"agent","to":"planning","label":"","variant":"security","fromSide":"right","toSide":"top","via":[[765,495],[765,690],[870,690]]}, - {"id":"govern-delivery","from":"agent","to":"delivery","label":"","variant":"security","fromSide":"bottom","toSide":"top"}, - {"id":"govern-uat","from":"agent","to":"uat","label":"","variant":"security","fromSide":"left","toSide":"top","via":[[435,495],[435,690],[330,690]]}, - {"id":"govern-security","from":"agent","to":"security","label":"","variant":"security","fromSide":"left","toSide":"right"}, - {"id":"govern-releases","from":"agent","to":"releases","label":"","variant":"security","fromSide":"left","toSide":"bottom","via":[[435,495],[435,345],[330,345]]}, + {"id":"govern-scope","from":"agent","to":"scope","label":"","variant":"security","fromSide":"top","toSide":"bottom","route":"straight"}, + {"id":"govern-architecture","from":"agent","to":"architecture","label":"","variant":"security","fromSide":"top","toSide":"bottom","via":[[600,345],[870,345]]}, + {"id":"govern-ui-ux","from":"agent","to":"ui-ux","label":"","variant":"security","fromSide":"right","toSide":"left","route":"straight"}, + {"id":"govern-planning","from":"agent","to":"planning","label":"","variant":"security","fromSide":"bottom","toSide":"top","via":[[600,645],[870,645]]}, + {"id":"govern-delivery","from":"agent","to":"delivery","label":"","variant":"security","fromSide":"bottom","toSide":"top","route":"straight"}, + {"id":"govern-uat","from":"agent","to":"uat","label":"","variant":"security","fromSide":"bottom","toSide":"top","via":[[600,645],[330,645]]}, + {"id":"govern-security","from":"agent","to":"security","label":"","variant":"security","fromSide":"left","toSide":"right","route":"straight"}, + {"id":"govern-releases","from":"agent","to":"releases","label":"","variant":"security","fromSide":"top","toSide":"bottom","via":[[600,345],[330,345]]}, {"id":"stage-1","from":"scope","to":"architecture","label":"Then","variant":"emphasis","fromSide":"right","toSide":"left"}, {"id":"stage-2","from":"architecture","to":"ui-ux","label":"Then","variant":"emphasis","fromSide":"bottom","toSide":"top"}, {"id":"stage-3","from":"ui-ux","to":"planning","label":"Then","variant":"emphasis","fromSide":"bottom","toSide":"top"}, diff --git a/package.json b/package.json index 77223e3..aa22bcc 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "verify:build": "node scripts/verify.mjs --build", "diagrams:build": "node scripts/build-diagrams.mjs", "test": "node --test scripts/*.test.mjs", - "test:browser": "node scripts/browser/strategy-story.cjs && node scripts/browser/inline-controls.cjs && node scripts/browser/sdlc-cycle.cjs && node scripts/browser/skills-measurement.cjs && node scripts/browser/roadmap.cjs", + "test:browser": "node scripts/browser/strategy-story.cjs && node scripts/browser/inline-controls.cjs && node scripts/browser/sdlc-cycle.cjs && node scripts/browser/target-midpoints.cjs && node scripts/browser/skills-measurement.cjs && node scripts/browser/roadmap.cjs", "kpi:example": "node scripts/ai-kpi.mjs" }, "dependencies": { diff --git a/scripts/browser/target-midpoints.cjs b/scripts/browser/target-midpoints.cjs new file mode 100644 index 0000000..d2e7518 --- /dev/null +++ b/scripts/browser/target-midpoints.cjs @@ -0,0 +1,85 @@ +const {chromium} = require('playwright'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +// Inspect the rendered paths, not just the authored JSON. Also runs on downloaded SVGs. +function inspect({mobile, xml}) { + let svg = document.querySelector('svg[role="img"]'); + if (xml) { + svg = new DOMParser().parseFromString(xml, 'image/svg+xml').documentElement; + document.body.appendChild(svg); + } + const point = (element, p) => new DOMPoint(p.x, p.y).matrixTransform(element.getCTM()); + const boxes = [...svg.querySelectorAll('[data-node-id]')].map(node => { + const rect = node.querySelector('rect'), box = rect.getBBox(); + const a = point(rect, box), b = point(rect, {x:box.x + box.width, y:box.y + box.height}); + return {id:node.dataset.nodeId, x:a.x, y:a.y, right:b.x, bottom:b.y, cx:(a.x+b.x)/2, cy:(a.y+b.y)/2}; + }); + const agent = boxes.find(n => n.id === 'agent'); + const errors = [], endpoints = {}; + const paths = [...svg.querySelectorAll('path[data-edge-id^="govern-"]')]; + if (paths.length !== 8) errors.push(`expected eight governance paths, got ${paths.length}`); + for (const path of paths) { + const id = path.dataset.edgeTo; + const side = mobile ? 'left' : ['scope','architecture','releases'].includes(id) ? 'top' + : ['planning','delivery','uat'].includes(id) ? 'bottom' : id === 'security' ? 'left' : 'right'; + const expected = {top:[agent.cx,agent.y],bottom:[agent.cx,agent.bottom],left:[agent.x,agent.cy],right:[agent.right,agent.cy]}[side]; + const start = point(path,path.getPointAtLength(0)); + endpoints[id] = [start.x,start.y]; + if (Math.hypot(start.x-expected[0],start.y-expected[1]) > 0.01) errors.push(`${id}: wrong ${side} midpoint`); + const length = path.getTotalLength(); + for (let distance = 1; distance < length; distance += 2) { + const p = point(path,path.getPointAtLength(distance)); + for (const box of boxes) { + if (box.id === id) continue; + if (p.x > box.x+0.1 && p.x < box.right-0.1 && p.y > box.y+0.1 && p.y < box.bottom-0.1) { + errors.push(`${id}: crosses ${box.id}`); break; + } + } + } + } + if (xml) svg.remove(); + return {errors:[...new Set(errors)],endpoints}; +} + +(async () => { + const base = process.argv[2] || 'http://127.0.0.1:4173'; + const output = `.cache/midpoints-${process.argv[3] || 'local'}`; + fs.mkdirSync(output,{recursive:true}); + const browser = await chromium.launch({headless:true, executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,args:['--no-sandbox']}); + const results = []; + try { + for (const width of [1440,390,320]) { + const page = await browser.newPage({viewport:{width,height:1000}}); + await page.goto(`${base}/eldorado-v1/documentation/strategy/target-operating-model/`,{waitUntil:'networkidle'}); + const panel = page.locator('[data-diagram="strategy-target"]'); + const iframe = panel.locator(`:scope > .story-${width > 700 ? 'desktop' : 'mobile'}`); + await iframe.scrollIntoViewIfNeeded(); + const frame = await (await iframe.elementHandle()).contentFrame(); + await frame.locator('svg[role="img"]').waitFor(); + const inline = await frame.evaluate(inspect,{mobile:width < 700}); + assert.deepEqual(inline.errors,[]); + await page.screenshot({path:`${output}/${width}-inline.png`,fullPage:true}); + await frame.locator('#btn-export').click(); + const [download] = await Promise.all([page.waitForEvent('download'),frame.locator('[data-format="svg"]').click()]); + assert.equal(await download.failure(),null); + const path = `${output}/${width}.svg`; + await download.saveAs(path); + const exported = await frame.evaluate(inspect,{mobile:width < 700,xml:fs.readFileSync(path,'utf8')}); + assert.deepEqual(exported.errors,[]); + await panel.getByRole('button',{name:/^Expand /}).click(); + const full = await (await panel.locator('dialog iframe').elementHandle()).contentFrame(); + await full.locator('svg[role="img"]').waitFor(); + // The existing fullscreen viewer always opens the radial desktop source. + const fullscreen = await full.evaluate(inspect,{mobile:false}); + assert.deepEqual(fullscreen.errors,[]); + await page.screenshot({path:`${output}/${width}-fullscreen.png`}); + await panel.getByRole('button',{name:'Close full-screen diagram'}).click(); + results.push({width,inline,exported,fullscreen}); + fs.writeFileSync(`${output}/results.json`,JSON.stringify(results,null,2)); + await page.close(); + } + assert.equal(results.length,3); + console.log('PASS midpoint geometry and no node crossings: 3 viewports × inline/fullscreen/exported SVG'); + } finally {await browser.close();} +})().catch(error => {console.error(error);process.exitCode=1;}); diff --git a/scripts/strategy-story.test.mjs b/scripts/strategy-story.test.mjs index 4f2d400..13c7121 100644 --- a/scripts/strategy-story.test.mjs +++ b/scripts/strategy-story.test.mjs @@ -36,7 +36,7 @@ test('mobile story diagrams preserve the same nodes and directed relationships', const desktop = JSON.parse(read(`diagrams/${name}.architecture.json`)); const mobile = JSON.parse(read(`diagrams/${name}-mobile.architecture.json`)); assert(desktop.components.length >= 5 && desktop.components.length <= (name === 'strategy-target' ? 18 : 7), name); - const semantic = d => ({nodes:d.components.map(({pos,size,...n})=>n),edges:d.connections.map(({fromSide,toSide,via,labelAt,...e})=>e)}); + const semantic = d => ({nodes:d.components.map(({pos,size,...n})=>n),edges:d.connections.map(({fromSide,toSide,route,via,labelAt,...e})=>e)}); assert.deepEqual(semantic(desktop), semantic(mobile), name); assert(desktop.connections.every(e => (e.label || e.id.startsWith('govern-')) && e.from !== e.to), name); assert.equal(desktop.meta.legend.mode, 'hidden'); diff --git a/scripts/target-midpoints.test.mjs b/scripts/target-midpoints.test.mjs new file mode 100644 index 0000000..b1b963e --- /dev/null +++ b/scripts/target-midpoints.test.mjs @@ -0,0 +1,31 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {createRouter} from '../vendor/archify/renderers/architecture/routing.mjs'; +import {segmentIntersectsRect} from '../vendor/archify/renderers/shared/geometry.mjs'; + +for (const suffix of ['', '-mobile']) { + test(`Target${suffix} uses shared native midpoint ports without node crossings`, () => { + const diagram = JSON.parse(readFileSync(`diagrams/strategy-target${suffix}.architecture.json`, 'utf8')); + const boxes = new Map(diagram.components.map(({id, pos: [x, y], size: [width, height]}) => + [id, {id, x, y, width, height, cx: x + width / 2, cy: y + height / 2}])); + const router = createRouter(boxes, diagram.connections, {distinctAutomaticPorts: true}); + const agent = boxes.get('agent'); + for (const edge of diagram.connections.filter(edge => edge.from === 'agent')) { + const side = suffix ? 'left' : ['scope', 'architecture', 'releases'].includes(edge.to) ? 'top' + : ['planning', 'delivery', 'uat'].includes(edge.to) ? 'bottom' : edge.to === 'security' ? 'left' : 'right'; + assert.equal(edge.fromSide, side, edge.id); + assert(edge.via || (edge.route && edge.route !== 'auto'), `${edge.id} must opt out of automatic port spreading`); + const expected = {top: [agent.cx, agent.y], bottom: [agent.cx, agent.y + agent.height], + left: [agent.x, agent.cy], right: [agent.x + agent.width, agent.cy]}[side]; + const points = router.pathFor(edge).points; + assert.deepEqual(points[0], expected, edge.id); + for (let i = 1; i < points.length; i++) { + for (const box of boxes.values()) { + if ([edge.from, edge.to].includes(box.id)) continue; + assert(!segmentIntersectsRect({start: points[i - 1], end: points[i]}, box), `${edge.id} crosses ${box.id}`); + } + } + } + }); +}