Add initial fit and direct wheel zoom with preserved default presentation. Integrate five scoped GuardFix findings and AI generation preselection policy. Exclude Cell experiments and retain five historical guard failures.
401 lines
24 KiB
JavaScript
401 lines
24 KiB
JavaScript
// Chart Only browser verification against a running preview server:
|
|
// npx vite --config examples/org-chart-chart-only/vite.config.ts
|
|
// node examples/org-chart-chart-only/verify.mjs
|
|
// CHART_ONLY_URL (default http://127.0.0.1:6041) selects the server.
|
|
import assert from 'node:assert/strict';
|
|
import path from 'node:path';
|
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
|
|
|
|
const root = fileURLToPath(new URL('../../', import.meta.url));
|
|
const output = path.join(root, 'test-results/org-chart-chart-only');
|
|
await mkdir(output, { recursive: true });
|
|
const base = process.env.CHART_ONLY_URL ?? 'http://127.0.0.1:6041';
|
|
const browser = await launchChromium();
|
|
const results = [];
|
|
const consoleErrors = [];
|
|
const record = (id, name, pass, detail) => { results.push({ id, name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${name}`, detail === undefined ? '' : JSON.stringify(detail)); };
|
|
const check = (id, name, fn) => async (...args) => {
|
|
try { const detail = await fn(...args); record(id, name, true, detail); }
|
|
catch (error) { record(id, name, false, error.message); }
|
|
};
|
|
|
|
/** All geometry in CSS pixels relative to the canvas; bounds include connectors. */
|
|
const measure = page => page.evaluate(() => {
|
|
const sr = document.querySelector('.hm-orgchart-host').shadowRoot;
|
|
const vp = sr.querySelector('.hm-orgchart-viewport'); const box = vp.getBoundingClientRect();
|
|
let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;
|
|
for (const e of sr.querySelectorAll('.hm-orgchart-forest .hm-orgchart-organization, .hm-orgchart-forest .hm-orgchart-organization-connectors path')) {
|
|
const x = e.getBoundingClientRect(); if (!x.width && !x.height) continue;
|
|
l = Math.min(l, x.left); t = Math.min(t, x.top); r = Math.max(r, x.right); b = Math.max(b, x.bottom);
|
|
}
|
|
const css = getComputedStyle;
|
|
const feedback = sr.querySelector('.hm-orgchart-move-feedback')?.getBoundingClientRect();
|
|
return {
|
|
canvas: { left: box.left, top: box.top, width: box.width, height: box.height },
|
|
scale: +vp.dataset.scale, x: +vp.dataset.panX, y: +vp.dataset.panY, pending: vp.dataset.placementPending ?? null,
|
|
dragDrop: vp.dataset.dragDropEnabled,
|
|
margins: { l: l - box.left, t: t - box.top, r: box.right - r, b: box.bottom - b },
|
|
centerErrX: (l + r) / 2 - (box.left + box.width / 2), centerErrY: (t + b) / 2 - (box.top + box.height / 2),
|
|
inside: l >= box.left - 0.5 && t >= box.top - 0.5 && r <= box.right + 0.5 && b <= box.bottom + 0.5,
|
|
cards: sr.querySelectorAll('.hm-orgchart-employee').length,
|
|
organizations: sr.querySelectorAll('.hm-orgchart-organization').length,
|
|
connectors: sr.querySelectorAll('.hm-orgchart-forest .edge, .hm-orgchart-organization-connectors path, .hm-orgchart-employee-connectors path').length,
|
|
external: {
|
|
heading: !!sr.querySelector('.hm-orgchart-heading'), date: !!sr.querySelector('.hm-orgchart-date'),
|
|
selection: !!sr.querySelector('.hm-orgchart-expanded-selection'), toolbar: !!sr.querySelector('.hm-orgchart-expanded-toolbar'),
|
|
zoomRatio: !!sr.querySelector('.hm-orgchart-expanded output'),
|
|
feedbackVisible: !!feedback && feedback.width > 1 && feedback.height > 1,
|
|
siblingsOfCanvas: [...sr.querySelector('.hm-orgchart-expanded').children].filter(e => e !== vp && e.getBoundingClientRect().width > 1).map(e => e.className),
|
|
},
|
|
colors: { page: css(document.body).backgroundColor, shell: css(sr.querySelector('.hm-orgchart-shell')).backgroundColor, content: css(sr.querySelector('.hm-orgchart-expanded')).backgroundColor, canvas: css(vp).backgroundColor, canvasBorder: css(vp).borderTopWidth, contentPadding: css(sr.querySelector('.hm-orgchart-expanded')).paddingTop },
|
|
companyColors: [...new Set([...sr.querySelectorAll('.node[data-company-id]')].map(n => `${n.dataset.companyId}:${n.style.getPropertyValue('--hm-orgchart-expanded-primary')}`))],
|
|
pageOverflowX: document.documentElement.scrollWidth > innerWidth,
|
|
};
|
|
});
|
|
|
|
async function ready(page) {
|
|
await page.waitForFunction(() => {
|
|
const sr = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
|
const vp = sr?.querySelector('.hm-orgchart-viewport');
|
|
return vp && !vp.dataset.placementPending && sr.querySelector('.hm-orgchart-employee') && sr.querySelector('.hm-orgchart-shell')?.getAttribute('aria-busy') === 'false';
|
|
}, null, { timeout: 30000 });
|
|
// Two settled frames: no later Fit may move the view.
|
|
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
|
}
|
|
|
|
/** Samples the visible transform from navigation until settle; repeated
|
|
* visible Fits would show up as more than one distinct transform. */
|
|
async function visibleTransforms(page, ms = 1500) {
|
|
return page.evaluate(ms => new Promise(resolve => {
|
|
const seen = []; const start = performance.now();
|
|
const tick = () => {
|
|
const vp = document.querySelector('.hm-orgchart-host')?.shadowRoot?.querySelector('.hm-orgchart-viewport');
|
|
const forest = vp?.querySelector('.hm-orgchart-forest');
|
|
if (forest && !vp.dataset.placementPending) { const value = forest.style.transform; if (seen.at(-1) !== value) seen.push(value); }
|
|
if (performance.now() - start < ms) requestAnimationFrame(tick); else resolve(seen);
|
|
};
|
|
tick();
|
|
}), ms);
|
|
}
|
|
|
|
async function open(context, url) {
|
|
const page = await context.newPage();
|
|
page.on('console', message => { if (message.type() === 'error') consoleErrors.push({ url, text: message.text() }); });
|
|
page.on('pageerror', error => consoleErrors.push({ url, text: String(error) }));
|
|
await page.goto(url);
|
|
return page;
|
|
}
|
|
|
|
const near = (a, b, tolerance) => Math.abs(a - b) <= tolerance;
|
|
const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
|
|
|
// A/B: output and initial Fit for both consumers and all sizes.
|
|
for (const kind of ['vanilla', 'react']) {
|
|
for (const size of ['20', '100', '500']) {
|
|
const url = `${base}/${kind}.html?data=${size}`;
|
|
const page = await open(desktop, url);
|
|
const transforms = await visibleTransforms(page);
|
|
await ready(page);
|
|
const m = await measure(page);
|
|
await page.screenshot({ path: path.join(output, `${kind}-${size}-desktop.png`) });
|
|
const label = `${kind} ${size}`;
|
|
await check('A', `${label} Chart Only output`, () => {
|
|
assert.deepEqual(Object.entries(m.external).filter(([key, value]) => key === 'siblingsOfCanvas' ? value.length : value), []);
|
|
assert.equal(m.colors.canvas, 'rgb(255, 255, 255)'); assert.equal(m.colors.content, 'rgb(255, 255, 255)'); assert.equal(m.colors.shell, 'rgb(255, 255, 255)');
|
|
assert.equal(m.colors.canvasBorder, '0px'); assert.equal(m.colors.contentPadding, '0px');
|
|
assert.ok(m.cards > 0 && m.organizations > 0 && m.connectors > 0);
|
|
assert.ok(near(m.canvas.width, 1440, 1) && near(m.canvas.height, 900, 1), 'canvas fills window');
|
|
return { cards: m.cards, organizations: m.organizations, connectors: m.connectors, colors: m.companyColors };
|
|
})();
|
|
await check('B', `${label} initial Fit and center`, () => {
|
|
assert.ok(m.inside, 'bounds inside canvas');
|
|
assert.ok(Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, `center error ${m.centerErrX}, ${m.centerErrY}`);
|
|
assert.ok(m.scale <= 1);
|
|
assert.ok(Math.min(m.margins.l, m.margins.r, m.margins.t, m.margins.b) >= 15.5, 'edge margin');
|
|
assert.equal(transforms.length, 1, `visible transforms ${JSON.stringify(transforms)}`);
|
|
return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3), margins: Object.fromEntries(Object.entries(m.margins).map(([k, v]) => [k, +v.toFixed(2)])), visibleTransforms: transforms.length };
|
|
})();
|
|
await page.close();
|
|
}
|
|
}
|
|
|
|
// LR / Hybrid keep their layout and still fit.
|
|
for (const layout of ['lr', 'hybrid']) {
|
|
const page = await open(desktop, `${base}/vanilla.html?data=100&layout=${layout}`);
|
|
await ready(page);
|
|
const m = await measure(page);
|
|
await page.screenshot({ path: path.join(output, `vanilla-100-${layout}.png`) });
|
|
await check('B', `vanilla 100 ${layout} Fit and center`, () => {
|
|
assert.ok(m.inside && Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, JSON.stringify(m.margins));
|
|
return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3) };
|
|
})();
|
|
await page.close();
|
|
}
|
|
|
|
// C/D: wheel, Pan, popup, groups and replacement on the 500-person chart.
|
|
for (const kind of ['vanilla', 'react']) {
|
|
const page = await open(desktop, `${base}/${kind}.html?data=500`);
|
|
await ready(page);
|
|
const fitted = await measure(page);
|
|
const card = page.locator('.hm-orgchart-employee').nth(30);
|
|
const cardBox = await card.boundingBox();
|
|
const point = { x: cardBox.x + cardBox.width / 2, y: cardBox.y + cardBox.height / 2 };
|
|
const local = m => ({ x: (point.x - m.canvas.left - m.x) / m.scale, y: (point.y - m.canvas.top - m.y) / m.scale });
|
|
await page.mouse.move(point.x, point.y);
|
|
await page.mouse.wheel(0, -100);
|
|
await page.waitForTimeout(50);
|
|
const zoomedIn = await measure(page);
|
|
await check('C', `${kind} plain wheel up zooms in (no Ctrl/⌘)`, () => {
|
|
assert.ok(zoomedIn.scale > fitted.scale * 1.1, `${fitted.scale} -> ${zoomedIn.scale}`);
|
|
return { from: +fitted.scale.toFixed(4), to: +zoomedIn.scale.toFixed(4) };
|
|
})();
|
|
await check('C', `${kind} zoom anchors at pointer`, () => {
|
|
const before = local(fitted), after = local(zoomedIn);
|
|
const error = Math.hypot(before.x - after.x, before.y - after.y) * zoomedIn.scale;
|
|
const box = { x: cardBox.x, y: cardBox.y };
|
|
assert.ok(error < 0.5, `screen drift ${error}`);
|
|
return { screenDriftPx: +error.toFixed(4), card: box };
|
|
})();
|
|
await page.mouse.wheel(0, 100);
|
|
await page.waitForTimeout(50);
|
|
const zoomedOut = await measure(page);
|
|
await check('C', `${kind} plain wheel down zooms out`, () => {
|
|
assert.ok(zoomedOut.scale < zoomedIn.scale);
|
|
return { from: +zoomedIn.scale.toFixed(4), to: +zoomedOut.scale.toFixed(4) };
|
|
})();
|
|
for (let i = 0; i < 40; i++) await page.mouse.wheel(0, -120);
|
|
await page.waitForTimeout(50);
|
|
const maximum = (await measure(page)).scale;
|
|
for (let i = 0; i < 80; i++) await page.mouse.wheel(0, 120);
|
|
await page.waitForTimeout(50);
|
|
const minimum = (await measure(page)).scale;
|
|
await check('C', `${kind} min/max limits`, () => {
|
|
assert.equal(maximum, 3);
|
|
assert.ok(near(minimum, Math.min(0.05, fitted.scale), 1e-9), `min ${minimum} fit ${fitted.scale}`);
|
|
return { max: maximum, min: +minimum.toFixed(4), fitScale: +fitted.scale.toFixed(4) };
|
|
})();
|
|
// Back to a readable zoom around the same card, then Pan from empty space.
|
|
await page.mouse.move(point.x, point.y);
|
|
for (let i = 0; i < 6; i++) await page.mouse.wheel(0, -100);
|
|
await page.waitForTimeout(50);
|
|
const beforePan = await measure(page);
|
|
const empty = { x: beforePan.canvas.left + 6, y: beforePan.canvas.top + beforePan.canvas.height - 6 };
|
|
const hit = await page.evaluate(({ x, y }) => { const sr = document.querySelector('.hm-orgchart-host').shadowRoot; const e = sr.elementFromPoint(x, y); return e?.closest('.hm-orgchart-employee, button') ? 'control' : e?.className ?? ''; }, empty);
|
|
await page.mouse.move(empty.x, empty.y); await page.mouse.down();
|
|
await page.mouse.move(empty.x + 60, empty.y - 40, { steps: 4 }); await page.mouse.move(empty.x + 120, empty.y - 80, { steps: 4 });
|
|
await page.mouse.up();
|
|
const afterPan = await measure(page);
|
|
await check('C', `${kind} Pan after Zoom`, () => {
|
|
assert.notEqual(hit, 'control');
|
|
assert.ok(near(afterPan.x - beforePan.x, 120, 0.5) && near(afterPan.y - beforePan.y, -80, 0.5), `${afterPan.x - beforePan.x}, ${afterPan.y - beforePan.y}`);
|
|
assert.equal(afterPan.scale, beforePan.scale);
|
|
return { dx: afterPan.x - beforePan.x, dy: afterPan.y - beforePan.y, scale: +afterPan.scale.toFixed(4) };
|
|
})();
|
|
await page.waitForTimeout(600);
|
|
const settled = await measure(page);
|
|
await check('C', `${kind} no automatic Fit after user Zoom/Pan`, () => {
|
|
assert.deepEqual([settled.scale, settled.x, settled.y], [afterPan.scale, afterPan.x, afterPan.y]);
|
|
})();
|
|
// Ctrl + wheel also zooms in the canvas and the browser page zoom is not used.
|
|
await page.keyboard.down('Control'); await page.mouse.move(point.x, point.y); await page.mouse.wheel(0, -100); await page.keyboard.up('Control');
|
|
await page.waitForTimeout(50);
|
|
const ctrlZoom = await measure(page);
|
|
const pageZoom = await page.evaluate(() => [visualViewport.scale, innerWidth]);
|
|
await check('C', `${kind} Ctrl + wheel stays inside the canvas`, () => {
|
|
assert.ok(ctrlZoom.scale > settled.scale); assert.deepEqual(pageZoom, [1, 1440]);
|
|
return { scale: +ctrlZoom.scale.toFixed(4) };
|
|
})();
|
|
|
|
// D: employee popup keeps the view.
|
|
const view = await measure(page);
|
|
const visibleCard = page.locator('.hm-orgchart-employee').filter({ hasNot: page.locator('[hidden]') }).nth(30);
|
|
await visibleCard.click();
|
|
const dialog = page.locator('dialog.hm-orgchart-employee-dialog');
|
|
const opened = await dialog.evaluate(element => element.open).catch(() => false);
|
|
const dialogText = opened ? (await dialog.innerText()).replace(/\s+/g, ' ').slice(0, 80) : '';
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(100);
|
|
const closed = !(await dialog.evaluate(element => element.open));
|
|
const afterDialog = await measure(page);
|
|
await check('D', `${kind} employee popup open/close keeps Zoom/Pan`, () => {
|
|
assert.ok(opened, 'dialog opened'); assert.ok(closed, 'dialog closed');
|
|
assert.deepEqual([afterDialog.scale, afterDialog.x, afterDialog.y], [view.scale, view.x, view.y]);
|
|
return { dialogText };
|
|
})();
|
|
// D: group collapse/expand keeps the view and does not Fit.
|
|
const toggle = page.locator('.hm-orgchart-expanded-toggle[aria-disabled="false"]').nth(3);
|
|
const id = await toggle.getAttribute('data-organization-id');
|
|
const cardsBefore = afterDialog.cards;
|
|
await toggle.click();
|
|
await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`)?.getAttribute('aria-expanded') === 'false', id);
|
|
await ready(page);
|
|
const collapsed = await measure(page);
|
|
await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`).click();
|
|
await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`)?.getAttribute('aria-expanded') === 'true', id);
|
|
await ready(page);
|
|
const expanded = await measure(page);
|
|
await check('D', `${kind} group collapse/expand without auto Fit`, () => {
|
|
assert.ok(collapsed.cards < cardsBefore && expanded.cards === cardsBefore, `${cardsBefore} -> ${collapsed.cards} -> ${expanded.cards}`);
|
|
assert.deepEqual([collapsed.scale, expanded.scale], [view.scale, view.scale]);
|
|
assert.deepEqual([expanded.x, expanded.y], [view.x, view.y]);
|
|
return { cards: [cardsBefore, collapsed.cards, expanded.cards], scale: +view.scale.toFixed(4) };
|
|
})();
|
|
// D: Drag & Drop defaults to OFF; a card drag never changes membership.
|
|
const dragSource = page.locator('.hm-orgchart-employee').nth(40);
|
|
const sourceId = await dragSource.getAttribute('data-employee-id');
|
|
const ownerBefore = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"]`)?.closest('.node')?.dataset.organizationId, sourceId);
|
|
const sourceBox = await dragSource.boundingBox();
|
|
const targetBox = await page.locator('.hm-orgchart-expanded-toggle').nth(1).boundingBox();
|
|
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); await page.mouse.down();
|
|
await page.mouse.move(targetBox.x + 10, targetBox.y + 5, { steps: 10 }); await page.mouse.up();
|
|
await page.waitForTimeout(200);
|
|
const afterDrag = await measure(page);
|
|
const owner = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"]`)?.closest('.node')?.dataset.organizationId, sourceId);
|
|
await page.keyboard.press('Escape');
|
|
await check('D', `${kind} Drag & Drop default OFF`, () => {
|
|
assert.equal(afterDrag.dragDrop, 'false');
|
|
assert.ok(owner && owner === ownerBefore, `${ownerBefore} -> ${owner}`);
|
|
return { dragDropEnabled: afterDrag.dragDrop, owner };
|
|
})();
|
|
// B16 / React re-render: user view survives a plain re-render, replacement Fits again.
|
|
if (kind === 'react') {
|
|
const userView = await measure(page);
|
|
await page.evaluate(() => window.rerenderPage());
|
|
await page.waitForTimeout(400);
|
|
const rerendered = await measure(page);
|
|
await check('C', 'react plain re-render keeps user view (no Fit)', () => {
|
|
assert.deepEqual([rerendered.scale, rerendered.x, rerendered.y], [userView.scale, userView.x, userView.y]);
|
|
})();
|
|
}
|
|
await page.evaluate(() => window.replaceData('100'));
|
|
await page.waitForFunction(() => document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee').length === 100);
|
|
await ready(page);
|
|
const replaced = await measure(page);
|
|
await page.screenshot({ path: path.join(output, `${kind}-replaced-100.png`) });
|
|
await check('B', `${kind} full data replacement Fits again`, () => {
|
|
assert.ok(replaced.inside && Math.abs(replaced.centerErrX) <= 1 && Math.abs(replaced.centerErrY) <= 1, JSON.stringify(replaced.margins));
|
|
return { scale: +replaced.scale.toFixed(4), centerErrX: +replaced.centerErrX.toFixed(3), centerErrY: +replaced.centerErrY.toFixed(3) };
|
|
})();
|
|
await page.close();
|
|
}
|
|
|
|
// C23: outside the canvas the page scrolls; inside, the wheel zooms.
|
|
for (const kind of ['vanilla', 'react']) {
|
|
const page = await open(desktop, `${base}/${kind}.html?data=100&embed=1`);
|
|
await ready(page);
|
|
await page.mouse.move(700, 300);
|
|
await page.mouse.wheel(0, 200);
|
|
await page.waitForTimeout(150);
|
|
const scrolled = await page.evaluate(() => scrollY);
|
|
const box = await page.locator('#chart').boundingBox();
|
|
const before = await measure(page);
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
await page.mouse.wheel(0, -100);
|
|
await page.waitForTimeout(150);
|
|
const after = await measure(page);
|
|
const scrollAfter = await page.evaluate(() => scrollY);
|
|
await check('C', `${kind} page scroll outside canvas / zoom inside`, () => {
|
|
assert.ok(scrolled >= 150, `outside scrollY ${scrolled}`);
|
|
assert.equal(scrollAfter, scrolled, 'inside wheel does not scroll the page');
|
|
assert.ok(after.scale > before.scale);
|
|
return { outsideScrollY: scrolled, insideScrollY: scrollAfter, scale: [+before.scale.toFixed(4), +after.scale.toFixed(4)] };
|
|
})();
|
|
await page.close();
|
|
}
|
|
|
|
// D28: optional HM Dialog adapter.
|
|
{
|
|
const page = await open(desktop, `${base}/react-hm-dialog.html?data=20`);
|
|
await ready(page);
|
|
const view = await measure(page);
|
|
await page.locator('.hm-orgchart-employee').nth(4).click();
|
|
const hmDialog = page.locator('[data-slot="dialog-content"]');
|
|
await hmDialog.waitFor({ timeout: 5000 });
|
|
await page.waitForTimeout(400);
|
|
await page.screenshot({ path: path.join(output, 'react-hm-dialog-open.png') });
|
|
const text = (await hmDialog.innerText()).replace(/\s+/g, ' ').slice(0, 80);
|
|
const coreDialogOpen = await page.locator('dialog.hm-orgchart-employee-dialog').evaluate(element => element.open);
|
|
await page.keyboard.press('Escape');
|
|
await hmDialog.waitFor({ state: 'detached', timeout: 5000 });
|
|
const after = await measure(page);
|
|
await check('D', 'react /hm-dialog Chart Only uses HM Dialog', () => {
|
|
assert.equal(coreDialogOpen, false);
|
|
assert.deepEqual([after.scale, after.x, after.y], [view.scale, view.x, view.y]);
|
|
return { text };
|
|
})();
|
|
await page.close();
|
|
}
|
|
// Preserve the untouched initial Fit through controller recreation. The manual
|
|
// minimum is deliberately above Fit to exercise the saved floor without 2,100+ data.
|
|
for (const kind of ['vanilla', 'react']) {
|
|
const page = await open(desktop, `${base}/${kind}.html?data=500&verify-min=1`);
|
|
await ready(page);
|
|
const initial = await measure(page);
|
|
const title = page.locator('.hm-orgchart-expanded-toggle').nth(1);
|
|
await title.click();
|
|
await page.waitForTimeout(250);
|
|
const collapsed = await measure(page);
|
|
await check('R1', `${kind} untouched Fit survives collapse`, () => {
|
|
assert.ok(initial.scale < 0.8);
|
|
assert.deepEqual([collapsed.scale, collapsed.x, collapsed.y], [initial.scale, initial.x, initial.y]);
|
|
})();
|
|
await title.click(); await page.waitForTimeout(250);
|
|
const expanded = await measure(page);
|
|
const viewport = page.locator('.hm-orgchart-viewport'); const box = await viewport.boundingBox();
|
|
await page.mouse.move(box.x + 8, box.y + box.height - 8); await page.mouse.wheel(0, 120);
|
|
await page.waitForTimeout(150);
|
|
const zoomed = await measure(page);
|
|
await check('R2', `${kind} recreated controller retains below-min Fit floor`, () => {
|
|
assert.deepEqual([expanded.scale, expanded.x, expanded.y], [initial.scale, initial.x, initial.y]);
|
|
assert.equal(zoomed.scale, initial.scale, 'wheel out must not jump to 0.8');
|
|
})();
|
|
if (kind === 'vanilla') {
|
|
await page.evaluate(() => window.orgChart.setInteractions({ zoom: false }));
|
|
await page.evaluate(() => window.orgChart.setInteractions({ zoom: true }));
|
|
const changed = await measure(page);
|
|
await check('R3', 'Vanilla interaction recreation retains initial view', () => {
|
|
assert.deepEqual([changed.scale, changed.x, changed.y], [initial.scale, initial.x, initial.y]);
|
|
})();
|
|
}
|
|
await page.close();
|
|
}
|
|
await desktop.close();
|
|
|
|
// Mobile: initial Fit, touch Pan and layout only (no wheel claims).
|
|
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
|
|
for (const kind of ['vanilla', 'react']) {
|
|
for (const size of ['20', '100', '500']) {
|
|
const page = await open(mobile, `${base}/${kind}.html?data=${size}`);
|
|
await ready(page);
|
|
const m = await measure(page);
|
|
if (size !== '100') await page.screenshot({ path: path.join(output, `${kind}-${size}-mobile.png`) });
|
|
let touch;
|
|
if (size === '20') {
|
|
const cdp = await mobile.newCDPSession(page);
|
|
const start = { x: m.canvas.left + 8, y: m.canvas.top + m.canvas.height - 8 };
|
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [start] });
|
|
for (let i = 1; i <= 5; i++) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start.x + i * 12, y: start.y - i * 10 }] });
|
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
|
const moved = await measure(page);
|
|
touch = { dx: moved.x - m.x, dy: moved.y - m.y };
|
|
}
|
|
await check('B', `${kind} ${size} mobile Fit${touch ? ' + touch Pan' : ''}`, () => {
|
|
assert.ok(m.inside && Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, JSON.stringify(m.margins));
|
|
assert.equal(m.pageOverflowX, false);
|
|
if (touch) assert.ok(Math.abs(touch.dx) > 20 && Math.abs(touch.dy) > 20, JSON.stringify(touch));
|
|
return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3), touch };
|
|
})();
|
|
await page.close();
|
|
}
|
|
}
|
|
await mobile.close();
|
|
await browser.close();
|
|
|
|
const failed = results.filter(result => !result.pass);
|
|
await writeFile(path.join(output, 'results.json'), JSON.stringify({ base, results, consoleErrors }, null, 2));
|
|
console.log(`\n${results.length - failed.length}/${results.length} PASS · console errors ${consoleErrors.length}`);
|
|
for (const error of consoleErrors) console.log('console:', error.url, error.text);
|
|
if (failed.length || consoleErrors.length) process.exitCode = 1;
|