feat: [verified] compose Skills Adoption diagrams around their stories
Build and publish Eldorado documentation / build (pull_request) Successful in 40s
Build and publish Eldorado documentation / build (pull_request) Successful in 40s
This commit is contained in:
1 parent
ef169cec58
commit
a35cade49c
19 files changed
+2377
-834
No files matched your search
@@ -0,0 +1,42 @@
|
||||
const {chromium}=require('playwright');
|
||||
const fs=require('node:fs');
|
||||
const path=require('node:path');
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{
|
||||
const dir=process.argv[2]||'.cache/skills-candidate';
|
||||
const browser=await chromium.launch({executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,args:['--no-sandbox']});
|
||||
const results=[];
|
||||
try {
|
||||
for(const topic of ['governance','quality','adoption','measurement','maintenance']) for(const width of [1440,1024,390,320]){
|
||||
const spec=JSON.parse(fs.readFileSync(`diagrams/strategy-${topic}${width<=1100?'-mobile':''}.architecture.json`));
|
||||
const page=await browser.newPage({viewport:{width:1200,height:1000}});
|
||||
await page.goto('file://'+path.resolve(`${dir}/${topic}-${width}.svg`));
|
||||
const measured=await page.locator('svg').evaluate(svg=>{
|
||||
const v=svg.viewBox.baseVal;const issues=[];
|
||||
for(const el of svg.querySelectorAll('[data-node-id],path[data-edge-id],g[data-edge-id]')){
|
||||
const b=el.getBBox();if(b.x<v.x-1||b.y<v.y-1||b.x+b.width>v.x+v.width+1||b.y+b.height>v.y+v.height+1)issues.push(el.getAttribute('data-node-id')||el.getAttribute('data-edge-id'));
|
||||
}
|
||||
// Measure the whole rendered label group, including its opaque mask:
|
||||
// text presence alone cannot detect one label hiding another.
|
||||
const labels=[...svg.querySelectorAll('g[data-edge-id]')].filter(el=>el.querySelector('text')).map(el=>({id:el.dataset.edgeId,box:el.getBoundingClientRect()}));
|
||||
const labelOverlaps=[];
|
||||
for(let i=0;i<labels.length;i++)for(let j=i+1;j<labels.length;j++){
|
||||
const a=labels[i],b=labels[j];
|
||||
if(Math.min(a.box.right,b.box.right)>Math.max(a.box.left,b.box.left)&&Math.min(a.box.bottom,b.box.bottom)>Math.max(a.box.top,b.box.top))labelOverlaps.push([a.id,b.id]);
|
||||
}
|
||||
return {labelOverlaps,viewBox:[v.width,v.height],nodes:[...svg.querySelectorAll('[data-node-id]')].map(n=>n.dataset.nodeId),edges:[...svg.querySelectorAll('path[data-edge-id]')].map(n=>n.dataset.edgeId),text:svg.textContent,issues};
|
||||
});
|
||||
assert.deepEqual(measured.viewBox,spec.meta.viewBox);
|
||||
assert.deepEqual(measured.nodes.sort(),spec.components.map(n=>n.id).sort());
|
||||
assert.deepEqual(measured.edges.sort(),spec.connections.map(e=>e.id).sort());
|
||||
assert.deepEqual(measured.issues,[]);
|
||||
assert.deepEqual(measured.labelOverlaps,[],`${topic}-${width}: rendered edge labels overlap`);
|
||||
for(const n of spec.components){assert(measured.text.includes(n.label));assert(measured.text.includes(n.tag));for(const line of n.sublabel.split('\n'))assert(measured.text.includes(line));}
|
||||
for(const e of spec.connections)if(e.label)assert(measured.text.includes(e.label));
|
||||
if(width===1440)await page.screenshot({path:`${dir}/${topic}-standalone-export.png`});
|
||||
results.push({topic,width,nodes:measured.nodes.length,edges:measured.edges.length,complete:true});await page.close();
|
||||
}
|
||||
fs.writeFileSync(`${dir}/export-results.json`,JSON.stringify(results,null,2));
|
||||
console.log(`PASS ${results.length} standalone SVGs: exact graph, text, full-canvas containment and non-overlapping edge labels`);
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -33,7 +33,7 @@ const fs = require('node:fs');
|
||||
const nodes = [...svg.querySelectorAll('[data-node-id]')].map(n => n.getBoundingClientRect().toJSON());
|
||||
return {nodes, width:innerWidth, height:innerHeight, minLabel:Math.min(...[...svg.querySelectorAll('text[data-node-label]')].map(n => parseFloat(getComputedStyle(n).fontSize)*n.getScreenCTM().a))};
|
||||
});
|
||||
assert.equal(geometry.nodes.length, 6);
|
||||
assert.equal(geometry.nodes.length, JSON.parse(fs.readFileSync('diagrams/strategy-measurement.architecture.json')).components.length);
|
||||
for (const n of geometry.nodes) assert(n.x >= 0 && n.y >= 0 && n.right <= geometry.width + 1 && n.bottom <= geometry.height + 1);
|
||||
assert(geometry.minLabel >= 10, `readable node labels: ${geometry.minLabel}`);
|
||||
await frame.locator('#btn-theme').click();
|
||||
@@ -50,7 +50,7 @@ const fs = require('node:fs');
|
||||
assert.equal(await download.failure(), null);
|
||||
const path = `${output}/${width}.svg`; await download.saveAs(path);
|
||||
const svg = fs.readFileSync(path, 'utf8');
|
||||
assert(svg.includes('Establish baseline') && svg.includes('Ongoing monitoring'));
|
||||
assert(svg.includes('Compare with baseline') && svg.includes('Zero denominator = N/A'));
|
||||
const opener = panel.getByRole('button', {name:/^Expand /});
|
||||
await opener.click();
|
||||
const close = panel.getByRole('button', {name:'Close full-screen diagram', exact:true});
|
||||
|
||||
@@ -11,7 +11,7 @@ const assert=require('node:assert/strict');
|
||||
const route=topic==='measurement'?topic:`skills-${topic}`;
|
||||
assert.equal((await page.goto(`${base}/eldorado-v1/documentation/strategy/${route}/`,{waitUntil:'networkidle'})).status(),200);
|
||||
const panel=page.locator('.archify-panel--skills');assert.equal(await panel.count(),1);
|
||||
const iframe=panel.locator(width>700?'.story-desktop':'.story-mobile');await iframe.scrollIntoViewIfNeeded();
|
||||
const iframe=panel.locator(width>1100?'.story-desktop':'.story-mobile');await iframe.scrollIntoViewIfNeeded();
|
||||
const frame=await(await iframe.elementHandle()).contentFrame();await frame.locator('svg[role="img"]').waitFor();
|
||||
const geometry=await frame.locator('svg[role="img"]').evaluate(svg=>{
|
||||
const nodes=[...svg.querySelectorAll('g[data-node-id]')];const issues=[];let min=Infinity;
|
||||
@@ -21,14 +21,15 @@ const assert=require('node:assert/strict');
|
||||
}}
|
||||
return {nodes:nodes.length,min,issues,width:innerWidth,svgWidth:svg.getBoundingClientRect().width};
|
||||
});
|
||||
assert.equal(geometry.nodes,6);assert.deepEqual(geometry.issues,[]);assert(geometry.min>=10,JSON.stringify(geometry));
|
||||
const source=JSON.parse(fs.readFileSync(`diagrams/strategy-${topic}.architecture.json`));
|
||||
assert.equal(geometry.nodes,source.components.length);assert.deepEqual(geometry.issues,[]);assert(geometry.min>=10,JSON.stringify(geometry));
|
||||
for(const theme of ['light','dark']){
|
||||
if(await frame.locator('html').getAttribute('data-theme')!==theme)await frame.locator('#btn-theme').click();
|
||||
await page.waitForFunction(t=>document.documentElement.dataset.theme===t,theme);
|
||||
await panel.scrollIntoViewIfNeeded();await page.screenshot({path:`${out}/${topic}-${width}-${theme}-inline.png`});
|
||||
const open=panel.getByRole('button',{name:/^Expand /});await open.click();const close=panel.getByRole('button',{name:'Close full-screen diagram',exact:true});await close.waitFor({state:'visible'});
|
||||
const expanded=await(await panel.locator('dialog iframe').elementHandle()).contentFrame();await expanded.locator('svg[role="img"]').waitFor();
|
||||
assert((await expanded.url()).includes(width<=700?`${topic}-mobile/`:`${topic}/`));
|
||||
assert((await expanded.url()).includes(width<=1100?`${topic}-mobile/`:`${topic}/`));
|
||||
await page.screenshot({path:`${out}/${topic}-${width}-${theme}-fullscreen.png`});
|
||||
if(width<=700){await page.setViewportSize({width,height:480});assert(await panel.locator('dialog').evaluate(n=>n.scrollHeight>n.clientHeight));await panel.locator('dialog').evaluate(n=>n.scrollTop=n.scrollHeight);await page.screenshot({path:`${out}/${topic}-${width}-${theme}-short-bottom.png`});await page.setViewportSize({width,height:900});}
|
||||
await close.focus();await page.keyboard.press('Escape');assert(await open.evaluate(n=>n===document.activeElement));
|
||||
|
||||
@@ -19,7 +19,9 @@ const fs=require('node:fs');
|
||||
const nodes=[...svg.querySelectorAll('[data-node-id]')].map(n=>{const r=n.getBoundingClientRect();return {id:n.id,x:r.x,y:r.y,right:r.right,bottom:r.bottom};});
|
||||
return {width:innerWidth,height:innerHeight,nodes,svg:svg.getBoundingClientRect().toJSON(),minLabel:Math.min(...[...svg.querySelectorAll('text[data-node-label]')].map(n=>parseFloat(getComputedStyle(n).fontSize)*n.getScreenCTM().a))};
|
||||
});
|
||||
assert(geometry.nodes.length>=(id==='roadmap'?2:5) && geometry.nodes.length<=(['index','target-operating-model'].includes(id)?18:7),id);
|
||||
const name=await panel.getAttribute('data-diagram');
|
||||
const source=JSON.parse(fs.readFileSync(`diagrams/${name}.${name==='strategy-roadmap'?'workflow':'architecture'}.json`));
|
||||
assert.equal(geometry.nodes.length,(source.components||source.nodes).length,id);
|
||||
for(const n of geometry.nodes) assert(n.x>=0 && n.y>=0 && n.right<=geometry.width+1 && n.bottom<=geometry.height+1,`${id} ${width} clipped ${JSON.stringify(n)} / ${geometry.height}`);
|
||||
const data=await page.locator('.strategy-content').evaluate(el=>({words:el.innerText.trim().split(/\s+/).length,overflow:document.documentElement.scrollWidth>innerWidth,openDetails:el.querySelectorAll('details[open]').length}));
|
||||
assert(data.words<=(id==='measurement'?1100:id==='roadmap'?600:200),id);assert(!data.overflow,id);assert.equal(data.openDetails,0,id);
|
||||
|
||||
@@ -21,28 +21,40 @@ for(const topic of topics) test(`${topic}: rich native source, wide desktop, mob
|
||||
const mobile=JSON.parse(read(`diagrams/${name}-mobile.architecture.json`));
|
||||
const semantic = d=>d.components.map(({pos,size,...n})=>n);
|
||||
assert.deepEqual(semantic(desktop),semantic(mobile));
|
||||
assert.equal(new Set(desktop.components.map(n=>n.pos[0])).size,3);
|
||||
assert(new Set(desktop.components.map(n=>n.pos[0])).size>1);
|
||||
assert.equal(new Set(mobile.components.map(n=>n.pos[0])).size,1);
|
||||
for(const n of desktop.components){assert(n.tag);assert(n.sublabel.split('\n').length>=4);}
|
||||
for(const n of desktop.components){assert(n.tag);assert(n.sublabel);assert(n.sublabel.split('\n').length<=4);}
|
||||
const dir=mkdtempSync(join(tmpdir(),'skills-viewer-'));
|
||||
try{
|
||||
const path=join(dir,'native.html');
|
||||
execFileSync(process.execPath,['vendor/archify/renderers/architecture/render-architecture.mjs',`diagrams/${name}.architecture.json`,path]);
|
||||
const native=read(path);const html=skillsViewer(native,desktop);
|
||||
assert.equal((html.match(/<g\b[^>]*data-node-id=/g)||[]).length,6);
|
||||
assert.equal((html.match(/<g\b[^>]*data-node-id=/g)||[]).length,desktop.components.length);
|
||||
assert.equal((html.match(/<text data-node-label=/g)||[]).length,0); // Adapter uses explicit position before semantic attributes.
|
||||
assert.equal((html.match(/data-node-label=""/g)||[]).length,6);
|
||||
assert.equal((html.match(/data-node-label=""/g)||[]).length,desktop.components.length);
|
||||
assert(html.includes('eldorado-skills-typography'));
|
||||
assert.throws(()=>skillsViewer(native.replace('data-node-id="'+desktop.components[0].id+'"','data-node-id="missing"'),desktop));
|
||||
const bad=structuredClone(desktop);bad.components[0].sublabel='not a full story';assert.throws(()=>skillsViewer(native,bad));
|
||||
const bad=structuredClone(desktop);bad.components[0].sublabel='';assert.throws(()=>skillsViewer(native,bad));
|
||||
}finally{rmSync(dir,{recursive:true,force:true});}
|
||||
});
|
||||
test('domain contracts retain human authority, honest measures and Delivery-first rollout',()=>{
|
||||
const text=t=>read(`diagrams/strategy-${t}.architecture.json`);
|
||||
const text=t=>JSON.stringify(JSON.parse(read(`diagrams/strategy-${t}.architecture.json`)));
|
||||
assert(text('governance').includes('Author is not sole approver'));
|
||||
assert(text('quality').includes('Critical effect blocks'));
|
||||
assert(text('adoption').includes('Delivery first')&&text('adoption').includes('Later order undecided'));
|
||||
assert(text('adoption').includes('No quotas or rankings'));
|
||||
for(const marker of ['Tracker:','Invoices + review minutes','linked defect records','Anonymous 1–5 survey','Missing buckets = partial','Zero denominator = N/A'])assert(text('measurement').includes(marker),marker);
|
||||
for(const marker of ['Tracker + acceptance','Invoices + review minutes','Changes + defects','Anonymous 1–5 survey','Missing buckets = partial','Zero denominator = N/A'])assert(text('measurement').includes(marker),marker);
|
||||
assert(text('maintenance').includes('No automatic reinstatement'));
|
||||
});
|
||||
test('topic compositions explain distinct relationships rather than a fixed card count',()=>{
|
||||
const spec=t=>JSON.parse(read(`diagrams/strategy-${t}.architecture.json`));
|
||||
const has=(d,a,b)=>assert(d.connections.some(e=>e.from===a&&e.to===b),`${a} → ${b}`);
|
||||
const g=spec('governance');const steps=g.components.filter(n=>/^0[1-6]/.test(n.label));
|
||||
assert.equal(steps.length,6);
|
||||
for(let i=1;i<steps.length;i++){assert(steps[i].pos[0]>steps[i-1].pos[0]);assert(steps[i].pos[1]>steps[i-1].pos[1]);has(g,steps[i-1].id,steps[i].id);}
|
||||
has(g,'approve','owner');has(g,'distribute','withdraw');
|
||||
const q=spec('quality');for(const id of ['selection','safety','artifact','outcome'])has(q,id,'gate');has(q,'gate','accept');has(q,'gate','rework');
|
||||
const a=spec('adoption');for(const [x,y] of [['human','agent'],['agent','human'],['agent','learn'],['learn','delivery'],['learn','expand'],['human','expand']])has(a,x,y);
|
||||
const m=spec('measurement');for(const id of ['success','quality','cost','confidence']){has(m,id+'-source',id);has(m,id,'interpret');}
|
||||
const c=spec('maintenance');for(const [x,y] of [['observe','triage'],['triage','change'],['change','validate'],['validate','release'],['release','observe'],['triage','safe'],['safe','observe'],['triage','retire'],['triage','contain'],['contain','change']])has(c,x,y);
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
// Keep native geometry, metadata, routing and exports; enrich only typography.
|
||||
// Native editable geometry and edges; scoped editorial typography for Stage 01.
|
||||
export const isSkillsStory = name => /^strategy-(governance|quality|adoption|measurement|maintenance)(-mobile)?$/.test(name);
|
||||
const escape = text => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
export function skillsViewer(html, spec) {
|
||||
if (spec.diagram_type !== 'architecture' || spec.components.length !== 6) throw new Error('Skills story contract drift');
|
||||
if (spec.diagram_type !== 'architecture' || !spec.components.length) throw new Error('Skills story contract drift');
|
||||
const detailSize = spec.meta.output.includes('-mobile/') ? 34 : 32;
|
||||
for (const node of spec.components) {
|
||||
const pattern = new RegExp(`<g\\b[^>]*data-node-id="${node.id}"[^>]*>[\\s\\S]*?<text data-node-label=[\\s\\S]*?</g>`);
|
||||
const match = html.match(pattern);
|
||||
@@ -12,24 +13,32 @@ export function skillsViewer(html, spec) {
|
||||
if (rectangles.length !== 2) throw new Error('Skills shape contract drift');
|
||||
const [x,y] = node.pos;
|
||||
const lines = node.sublabel.split('\n');
|
||||
if ((lines.length < 4 || lines.length > 5) || lines.some(line => line.length > 29) || node.label.length > 25) throw new Error(`Skills copy exceeds capacity: ${node.id}`);
|
||||
if (!node.tag || !node.sublabel || lines.length > 4) throw new Error(`Skills copy exceeds capacity: ${node.id}`);
|
||||
const text = (value, top, size, attrs) => `<text x="${x+28}" y="${y+top}" font-size="${size}" ${attrs}>${escape(value)}</text>`;
|
||||
const content = text(node.tag, 52, 36, 'class="t-muted" data-detail="context"')
|
||||
+ text(node.label, 118, 42, 'class="t-primary" data-node-label="" font-weight="700"')
|
||||
+ lines.map((line,i) => text(line, 190+i*46, 36, 'class="t-primary" data-detail="context"')).join('');
|
||||
const content = text(node.tag, 40, detailSize, 'class="t-muted" data-detail="context"')
|
||||
+ text(node.label, 89, 36, 'class="t-primary" data-node-label="" font-weight="700"')
|
||||
+ lines.map((line,i) => text(line, 138+i*42, detailSize, 'class="t-primary" data-detail="context"')).join('');
|
||||
const prefix = original.slice(0,rectangles[1].index+rectangles[1][0].length).replaceAll('rx="6"','rx="0"');
|
||||
html = html.replace(original,prefix+content+'</g>');
|
||||
}
|
||||
const roots = [...html.matchAll(/<svg\b[^>]*role="img"[^>]*>/g)];
|
||||
if (roots.length !== 1) throw new Error('Skills SVG contract drift');
|
||||
const width = Math.max(...spec.components.map(n=>n.pos[0]+n.size[0]))+40;
|
||||
const height = Math.max(...spec.components.map(n=>n.pos[1]+n.size[1]))+40;
|
||||
const [width,height] = spec.meta.viewBox;
|
||||
const root = roots[0][0].replace(/viewBox="[^"]*"/,`viewBox="0 0 ${width} ${height}"`);
|
||||
// Embedded in SVG so downloaded files retain typography without the viewer.
|
||||
html = html.replace(roots[0][0],root+`<style id="eldorado-skills-typography">
|
||||
[data-node-id] text {font-family:Arial,sans-serif;}
|
||||
.c-grid {opacity:0;}
|
||||
rect[fill="url(#grid)"] {opacity:0;}
|
||||
[data-node-id] > rect:not(.c-mask) {stroke-width:3;}
|
||||
[data-edge-id] {opacity:1;}
|
||||
</style>`);
|
||||
// Native labels use tiny technical annotations. Enlarge both text and mask,
|
||||
// retaining the native edge identity, routing and explicit label position.
|
||||
html = html.replace(/(<g data-detail="context"[^>]*data-edge-id[^>]*>)([\s\S]*?)(<\/g>)/g, (all,open,body,close) => {
|
||||
const text = body.match(/<text x="([^"]+)" y="([^"]+)"([^>]*)>([\s\S]*?)<\/text>/);
|
||||
if (!text) throw new Error('Skills edge label contract drift');
|
||||
const x=Number(text[1]), y=Number(text[2]), width=text[4].replace(/&[^;]+;/g,'x').length*17+24;
|
||||
return open+`<rect x="${x-width/2}" y="${y-34}" width="${width}" height="44" class="c-mask"/>`+text[0].replace('font-size="8"',`font-size="${detailSize}"`)+close;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
@@ -35,10 +35,10 @@ 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);
|
||||
assert(desktop.components.length >= 5, name);
|
||||
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-') || /^strategy-(governance|quality|adoption|maintenance)$/.test(name)) && e.from !== e.to), name);
|
||||
assert(desktop.connections.every(e => (e.label || e.id.startsWith('govern-') || /^strategy-(governance|quality|adoption|measurement|maintenance)$/.test(name)) && e.from !== e.to), name);
|
||||
assert.equal(desktop.meta.legend.mode, 'hidden');
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user