Files
HMDesign/examples/org-chart/verify-p9-3-core.mjs
T

372 lines
34 KiB
JavaScript

// P9.3 public Vanilla Core API and engine bridge checks only.
// Requires the parent-controlled static server and an already built Core.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
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-p9-3');
const origin = process.env.ORGCHART_CORE_ORIGIN ?? 'http://127.0.0.1:6064';
const only = process.argv.slice(2), run = section => !only.length || only.includes(section);
const suffix = only.length ? '-' + only.join('-') : '';
const startedAt = new Date().toISOString(), results = [], measurements = [], errors = [];
await fs.mkdir(output, { recursive: true });
const persist = () => writeFileSync(path.join(output, `core-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2));
const check = (pass, name, detail) => {
results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
persist(); if (!pass) console.log('FAIL', name, JSON.stringify(detail));
};
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(15000);
page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); });
page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 6; i++) await new Promise(requestAnimationFrame); });
async function ready() {
await page.waitForFunction(() => window.fixtureReady && window.chart?.getState().status === 'ready'
&& [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet));
await settle();
}
async function fresh(extra) {
await page.goto(`${origin}/vanilla.html`); await ready();
if (extra) { await page.evaluate(extra => window.mountChart('auto', extra), extra); await ready(); }
}
const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`);
const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
async function pointerMove(source, target) {
const from = await source.boundingBox(), to = await target.boundingBox();
if (!from || !to) throw new Error('Pointer move source/target has no rendered bounds');
const start = { x: from.x + from.width / 2, y: from.y + from.height / 2 }, end = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
await page.mouse.move(start.x, start.y); await page.mouse.down();
await page.mouse.move(start.x + 8, start.y + 8, { steps: 2 });
await page.mouse.move(end.x, end.y, { steps: 10 });
const feedback = await page.evaluate(() => [...window.shadow.querySelectorAll('.node[data-drop-state]')].map(node => ({ id: node.dataset.organizationId, state: node.dataset.dropState, hover: node.dataset.dropHover })));
await page.mouse.up(); await ready();
return feedback;
}
async function snapshot() {
return page.evaluate(() => {
const data = window.chart.getData(), state = window.chart.getState(), shadow = window.shadow;
return { data, summary: state.status === 'ready' ? state.summary.raw : undefined,
viewport: window.chart.getViewportState(), moves: window.moves, events: window.bridgeEvents,
cards: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, organization: card.closest('.node').dataset.organizationId, direction: card.dataset.connectionDirection })),
groups: [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => ({ id: node.dataset.organizationId, parent: node.dataset.parentId, collapsed: node.dataset.collapsed })),
organizationPaths: [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(node => ({ id: node.dataset.organizationId, parent: node.dataset.parentId })),
dialogCount: shadow.querySelectorAll('dialog[open]').length,
feedback: shadow.querySelector('.hm-orgchart-move-feedback')?.textContent };
});
}
try {
if (run('api')) {
await fresh();
const initial = await snapshot();
check(initial.data.employees.length === 20 && initial.data.organizations.length === 15, 'Vanilla public Core mounts raw multi-company fixture');
const frozen = await page.evaluate(() => {
const data = window.chart.getData(), before = JSON.stringify(data);
const deep = value => value === null || typeof value !== 'object' || (Object.isFrozen(value) && Object.values(value).every(deep));
try { data.employees[0].name = 'consumer mutation'; } catch { /* Frozen snapshots may throw in strict mode. */ }
return { deepFrozen: deep(data), unchanged: JSON.stringify(data) === before, copied: data !== window.fixture, fixtureUnchanged: JSON.stringify(window.fixture) === window.fixtureOriginal };
});
check(frozen.deepFrozen && frozen.unchanged && frozen.copied && frozen.fixtureUnchanged, 'getData is a deeply frozen owned snapshot; consumer input preserved', frozen);
const moved = await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }));
await ready(); const afterEmployee = await snapshot();
check(moved.moved && moved.previousOrganizationId === 'hanmac-office-0' && moved.request.sourceId === 'virtual-006', 'Public employee move reports original relation and request', moved.moved ? { previousOrganizationId: moved.previousOrganizationId, request: moved.request } : moved);
const beforePerson = initial.data.employees.find(person => person.id === 'virtual-006'), afterPerson = afterEmployee.data.employees.find(person => person.id === 'virtual-006');
check(afterPerson.organizationId === 'hanmac-division-1' && JSON.stringify({ ...afterPerson, organizationId: beforePerson.organizationId }) === JSON.stringify(beforePerson), 'Employee identity, affiliation and personal fields survive new organization');
check(afterEmployee.data.employees.length === initial.data.employees.length && new Set(afterEmployee.cards.map(card => card.id)).size === 20
&& afterEmployee.cards.filter(card => card.id === 'virtual-006').length === 1 && afterEmployee.cards.find(card => card.id === 'virtual-006').organization === 'hanmac-division-1', 'Employee count unchanged and one rendered card under new group');
check(afterEmployee.summary.total === initial.summary.total
&& afterEmployee.summary.byOrganization['hanmac-office-0'].direct === initial.summary.byOrganization['hanmac-office-0'].direct - 1
&& afterEmployee.summary.byOrganization['hanmac-division-1'].direct === initial.summary.byOrganization['hanmac-division-1'].direct + 1, 'Public summary direct totals update without changing overall count');
const group = await page.evaluate(() => window.chart.move({ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-division-1' }));
await ready(); const afterGroup = await snapshot();
check(group.moved && group.previousOrganizationId === 'hanmac-root'
&& afterGroup.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'hanmac-division-1'
&& afterGroup.data.organizations.find(org => org.id === 'hanmac-office-0').parentId === 'hanmac-division-0'
&& JSON.stringify(afterGroup.data.employees) === JSON.stringify(afterEmployee.data.employees), 'Public organization move updates parent only and retains descendants/staff', group.moved ? group.request : group);
const unchanged = JSON.stringify(afterGroup.data);
const invalid = [
[{ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-office-0' }, 'DESCENDANT_TARGET'],
[{ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-division-0' }, 'SELF_TARGET'],
[{ kind: 'organization', sourceId: 'hanmac-root', targetOrganizationId: 'hanmac-division-1' }, 'ROOT_PROTECTED'],
[{ kind: 'employee', sourceId: 'virtual-001', targetOrganizationId: 'hanmac-division-1' }, 'LEADER_PROTECTED'],
[{ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }, 'SAME_ORGANIZATION'],
[{ kind: 'employee', sourceId: 'missing-employee', targetOrganizationId: 'hanmac-division-1' }, 'SOURCE_NOT_FOUND'],
[{ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'missing-group' }, 'TARGET_NOT_FOUND'],
];
for (const [request, code] of invalid) {
const result = await page.evaluate(request => window.chart.move(request), request);
check(!result.moved && result.code === code && await page.evaluate(expected => JSON.stringify(window.chart.getData()) === expected, unchanged), `Public move rejects ${code} and preserves complete dataset`, result);
}
check(await page.evaluate(expected => window.moves.length === expected && window.moves.filter(move => move.moved).length === 2, 2 + invalid.length), 'onMove receives successful moves and every policy rejection exactly once');
const callback = await page.evaluate(async () => { window.throwCallback = true; const result = await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-office-1' }); window.throwCallback = false; return { moved: result.moved, status: window.chart.getState().status }; });
await ready(); check(callback.moved && callback.status === 'ready', 'Consumer onMove exception cannot fail Core transaction', callback);
check(await page.evaluate(() => JSON.stringify(window.fixture) === window.fixtureOriginal), 'All public edits leave original JSON input unchanged');
measurements.push({ section: 'api', state: await snapshot() });
}
if (run('cross')) {
// User's later instruction permits cross-company moves. The earlier
// CROSS_COMPANY rejection evidence remains preserved as superseded history.
await fresh(); const before = await snapshot();
const employeeResult = await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'saman-division-1' }));
await ready(); const afterEmployee = await snapshot();
const oldEmployee = before.data.employees.find(person => person.id === 'virtual-006'), newEmployee = afterEmployee.data.employees.find(person => person.id === 'virtual-006');
check(employeeResult.moved && employeeResult.previousOrganizationId === 'hanmac-office-0'
&& newEmployee.organizationId === 'saman-division-1' && newEmployee.affiliationId === 'saman'
&& JSON.stringify({ ...newEmployee, organizationId: oldEmployee.organizationId, affiliationId: oldEmployee.affiliationId }) === JSON.stringify(oldEmployee),
'Cross-company public employee move updates actual organizationId/affiliationId and preserves identity', employeeResult.moved ? { request: employeeResult.request, person: newEmployee } : employeeResult);
check(afterEmployee.data.employees.length === 20 && afterEmployee.cards.length === 20 && new Set(afterEmployee.cards.map(card => card.id)).size === 20
&& afterEmployee.summary.total === before.summary.total
&& afterEmployee.summary.byAffiliation.hanmac === before.summary.byAffiliation.hanmac - 1
&& afterEmployee.summary.byAffiliation.saman === before.summary.byAffiliation.saman + 1
&& afterEmployee.summary.byOrganization['hanmac-root'].inclusive === before.summary.byOrganization['hanmac-root'].inclusive - 1
&& afterEmployee.summary.byOrganization['saman-root'].inclusive === before.summary.byOrganization['saman-root'].inclusive + 1,
'Cross-company employee move retains total and updates source/target company plus affiliation counts');
const employeeColor = await employee('virtual-006').evaluate(card => {
const probe = document.createElement('span'); probe.style.color = window.mod.companyColorPresets.saman.primary; window.shadow.append(probe);
const expected = getComputedStyle(probe).color; probe.remove();
return { company: card.dataset.companyId, organization: card.closest('.node').dataset.organizationId, border: getComputedStyle(card).borderTopColor, expected };
});
check(employeeColor.company === 'saman' && employeeColor.organization === 'saman-division-1' && employeeColor.border === employeeColor.expected,
'Cross-company employee card uses destination existing company preset', employeeColor);
await employee('virtual-006').click(); const popup = page.locator('.hm-orgchart-employee-dialog[open]'); await popup.waitFor();
const popupState = await popup.evaluate(dialog => {
const field = name => [...dialog.querySelectorAll('dt')].find(term => term.textContent === name)?.nextElementSibling?.textContent;
return { id: dialog.dataset.employeeId, organization: field('소속'), affiliation: field('관계사') };
});
check(popupState.id === 'virtual-006' && popupState.organization === afterEmployee.data.organizations.find(org => org.id === 'saman-division-1').name
&& popupState.affiliation === afterEmployee.data.affiliations.find(affiliation => affiliation.id === 'saman').name,
'Vanilla employee Dialog reflects destination company and organization after cross move', popupState);
await page.keyboard.press('Escape'); await popup.waitFor({ state: 'hidden' }); await settle();
const groupResult = await page.evaluate(() => window.chart.move({ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'saman-division-1' }));
await ready(); const afterGroup = await snapshot();
const subtree = new Set(['hanmac-division-0', 'hanmac-office-0']);
const subtreePeople = afterEmployee.data.employees.filter(person => subtree.has(person.organizationId));
check(groupResult.moved && groupResult.previousOrganizationId === 'hanmac-root'
&& afterGroup.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'saman-division-1'
&& afterGroup.data.organizations.find(org => org.id === 'hanmac-office-0').parentId === 'hanmac-division-0'
&& afterGroup.data.organizations.filter(org => subtree.has(org.id)).every(org => org.companyId === 'saman')
&& afterGroup.data.employees.filter(person => subtree.has(person.organizationId)).every(person => person.affiliationId === 'saman')
&& subtreePeople.every(old => JSON.stringify(afterGroup.data.employees.find(person => person.id === old.id)) === JSON.stringify({ ...old, affiliationId: 'saman' })),
'Cross-company public group move retargets subtree companyId and staff affiliationId while retaining hierarchy and identities', groupResult.moved ? { request: groupResult.request, subtreePeople } : groupResult);
check(afterGroup.data.employees.length === 20 && afterGroup.cards.length === 20 && new Set(afterGroup.cards.map(card => card.id)).size === 20
&& afterGroup.summary.byAffiliation.hanmac === afterEmployee.summary.byAffiliation.hanmac - subtreePeople.length
&& afterGroup.summary.byAffiliation.saman === afterEmployee.summary.byAffiliation.saman + subtreePeople.length
&& afterGroup.summary.byOrganization['hanmac-root'].inclusive === afterEmployee.summary.byOrganization['hanmac-root'].inclusive - subtreePeople.length
&& afterGroup.summary.byOrganization['saman-root'].inclusive === afterEmployee.summary.byOrganization['saman-root'].inclusive + subtreePeople.length,
'Cross-company group transfer updates company/affiliation aggregate totals without duplication');
const groupColors = await page.evaluate(() => {
const probe = document.createElement('span'); probe.style.color = window.mod.companyColorPresets.saman.primary; window.shadow.append(probe);
const expected = getComputedStyle(probe).color; probe.remove();
return [...window.shadow.querySelectorAll('.node')].filter(node => ['hanmac-division-0', 'hanmac-office-0'].includes(node.dataset.organizationId))
.map(node => ({ id: node.dataset.organizationId, company: node.dataset.companyId, title: getComputedStyle(node.querySelector('.hm-orgchart-organization-name')).backgroundColor, expected,
cards: [...node.querySelectorAll('.hm-orgchart-employee')].map(card => ({ company: card.dataset.companyId, border: getComputedStyle(card).borderTopColor })) }));
});
check(groupColors.length === 2 && groupColors.every(node => node.company === 'saman' && node.title === node.expected
&& node.cards.every(card => card.company === 'saman' && card.border === node.expected)), 'Transferred subtree uses destination presets on group headings and employee borders', groupColors);
check(await page.evaluate(() => window.moves.length === 2 && window.moves.every(result => result.moved)
&& JSON.stringify(window.fixture) === window.fixtureOriginal && window.mod.validateOrgChartDataset(window.chart.getData()).valid
&& Object.isFrozen(window.chart.getData()) && Object.isFrozen(window.chart.getData().employees[0])),
'Cross-company API callbacks occur once per success; data remains valid/frozen and original fixture unchanged');
await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
check(await employee('virtual-006').count() === 0 && await title('hanmac-division-0').count() === 0, 'Destination-company filter removes transferred employee/subtree from source-company view');
await page.evaluate(() => window.chart.setSelectedCompanies(['saman'])); await ready();
check(await employee('virtual-006').count() === 1 && await title('hanmac-division-0').count() === 1 && await title('hanmac-office-0').count() === 1,
'Destination-company filter restores transferred employee/subtree exactly once');
measurements.push({ section: 'cross-api', state: await snapshot() });
await fresh();
const employeeMarks = await pointerMove(employee('virtual-006'), title('saman-division-1'));
const employeePointer = await snapshot();
check(employeeMarks.some(node => node.id === 'saman-division-1' && node.state === 'allowed' && node.hover === 'allowed')
&& employeePointer.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented && event.targetId === 'saman-division-1')
&& employeePointer.data.employees.find(person => person.id === 'virtual-006').organizationId === 'saman-division-1'
&& employeePointer.data.employees.find(person => person.id === 'virtual-006').affiliationId === 'saman',
'Real cross-tree employee pointer marks destination allowed and reuses canceled upstream external-drop bridge', { marks: employeeMarks, events: employeePointer.events });
await page.evaluate(() => window.chart.fitToView()); await settle();
const groupMarks = await pointerMove(title('hanmac-division-0'), title('saman-division-1'));
const groupPointer = await snapshot();
check(groupMarks.some(node => node.id === 'saman-division-1' && node.state === 'allowed' && node.hover === 'allowed')
&& groupPointer.events.some(event => event.type === 'nodedrop.orgchart' && event.defaultPrevented && event.targetId === 'saman-division-1')
&& groupPointer.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'saman-division-1'
&& groupPointer.data.organizations.find(org => org.id === 'hanmac-office-0').companyId === 'saman',
'Real cross-tree group pointer reuses canceled upstream node-drop bridge and retargets subtree', { marks: groupMarks, events: groupPointer.events });
check(groupPointer.moves.length === 2 && groupPointer.moves.every(move => move.moved) && groupPointer.dialogCount === 0
&& groupPointer.data.employees.length === 20 && new Set(groupPointer.cards.map(card => card.id)).size === 20
&& await page.evaluate(() => JSON.stringify(window.fixture) === window.fixtureOriginal),
'Cross-tree pointer transactions have one callback each, no popup/duplicate and immutable original fixture');
measurements.push({ section: 'cross-pointer', state: groupPointer });
await page.screenshot({ path: path.join(output, 'vanilla-cross-company.png'), fullPage: true });
}
if (run('bridge')) {
await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
const before = await snapshot();
const marks = await pointerMove(employee('virtual-006'), title('hanmac-division-1'));
const staff = await snapshot();
check(marks.some(group => group.id === 'hanmac-division-1' && group.state === 'allowed' && group.hover === 'allowed')
&& marks.some(group => group.id === 'hanmac-office-0' && group.state === 'blocked'), 'Vanilla pointer card gesture exposes valid/invalid/hover targets', marks);
check(staff.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented && event.sourceId === 'virtual-006' && event.targetId === 'hanmac-division-1')
&& staff.data.employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1', 'Real employee pointer uses canceled upstream otherdropped bridge and updates data', staff.events);
check(staff.dialogCount === 0 && JSON.stringify(staff.viewport) === JSON.stringify(before.viewport), 'Employee drag opens no Dialog and retains Pan/Zoom transform', { before: before.viewport, after: staff.viewport, dialogs: staff.dialogCount });
await page.evaluate(() => window.chart.fitToView()); await settle();
const groupMarks = await pointerMove(title('hanmac-division-0'), title('hanmac-division-1'));
const groups = await snapshot();
check(groupMarks.some(group => group.id === 'hanmac-office-0' && group.state === 'blocked')
&& groups.events.some(event => event.type === 'nodedrop.orgchart' && event.defaultPrevented && event.sourceId === 'hanmac-division-0' && event.targetId === 'hanmac-division-1')
&& groups.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'hanmac-division-1', 'Real organization pointer uses canceled upstream nodedrop bridge and protects descendant', { marks: groupMarks, events: groups.events });
check(groups.data.employees.length === 20 && new Set(groups.groups.map(group => group.id)).size === groups.groups.length
&& groups.groups.find(group => group.id === 'hanmac-office-0').parent === 'hanmac-division-0'
&& groups.groups.find(group => group.id === 'hanmac-division-0').collapsed === 'false', 'Group pointer move keeps complete subtree and avoids post-drag collapse');
await page.evaluate(() => window.chart.setAutoLayout({ type: 'lr' })); await ready();
const routed = await snapshot();
check(routed.organizationPaths.some(route => route.id === 'hanmac-division-0' && route.parent === 'hanmac-division-1')
&& !routed.organizationPaths.some(route => route.id === 'hanmac-division-0' && route.parent === 'hanmac-root')
&& routed.cards.every(card => card.direction === 'left'), 'LR regenerated connector follows changed parent and card incoming borders');
await page.screenshot({ path: path.join(output, 'vanilla-core-lr.png'), fullPage: true });
measurements.push({ section: 'bridge', state: routed });
}
if (run('lifecycle')) {
await fresh();
await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' })); await ready();
await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
await title('hanmac-division-1').click(); await ready();
const collapsed = await snapshot();
check(collapsed.groups.find(group => group.id === 'hanmac-division-1').collapsed === 'true', 'Moved employee target remains ordinarily collapsible');
for (const type of ['lr', 'hybrid', 'tb']) {
await page.evaluate(type => window.chart.setAutoLayout({ type }), type); await ready();
check(await title('hanmac-division-1').getAttribute('aria-expanded') === 'false'
&& await page.evaluate(() => window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1'), `Local relation and collapse survive layout ${type}`);
}
const hiddenState = await page.evaluate(() => JSON.stringify(window.chart.getData()));
await page.evaluate(() => window.chart.setSelectedCompanies(['saman'])); await ready();
check(await page.locator('.node[data-company-id="hanmac"]').count() === 0
&& await page.evaluate(expected => JSON.stringify(window.chart.getData()) === expected, hiddenState), 'Hiding a company preserves all browser-state relations');
await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
check(await title('hanmac-division-1').getAttribute('aria-expanded') === 'false', 'Company restore keeps saved collapsed target');
await title('hanmac-division-1').click(); await ready();
check(await employee('virtual-006').count() === 1 && await employee('virtual-006').evaluate(card => card.closest('.node').dataset.organizationId) === 'hanmac-division-1', 'Company restore shows moved employee exactly once in new group');
const raced = await page.evaluate(async () => {
let resolveSource;
const delayed = { load: () => new Promise(resolve => { resolveSource = resolve; }) };
const first = window.chart.reload(delayed);
const rejected = await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-office-1' });
const fresh = structuredClone(window.fixture); fresh.metadata.dataVersion = 'p9.3-newer-request';
await window.chart.update(fresh);
resolveSource(window.fixture); await first;
return { rejected, version: window.chart.getData().metadata.dataVersion, organization: window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId, status: window.chart.getState().status };
});
await ready();
check(!raced.rejected.moved && raced.rejected.code === 'INVALID_DATA', 'Move during pending source request is safely rejected', raced.rejected);
check(raced.version === 'p9.3-newer-request' && raced.organization === 'hanmac-office-0' && raced.status === 'ready', 'Stale asynchronous reload cannot overwrite newer dataset request', raced);
const simultaneous = await page.evaluate(async () => {
const one = window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' });
const two = window.chart.move({ kind: 'employee', sourceId: 'virtual-007', targetOrganizationId: 'hanmac-office-1' });
const outcomes = await Promise.all([one, two]);
return { outcomes: outcomes.map(result => ({ moved: result.moved, code: result.code })), data: window.chart.getData(), valid: window.mod.validateOrgChartDataset(window.chart.getData()).valid, status: window.chart.getState().status };
});
await ready();
check(simultaneous.outcomes[0].moved && !simultaneous.outcomes[1].moved && simultaneous.outcomes[1].code === 'INVALID_DATA'
&& simultaneous.valid && simultaneous.status === 'ready' && simultaneous.data.employees.length === 20
&& simultaneous.data.employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1'
&& simultaneous.data.employees.find(person => person.id === 'virtual-007').organizationId === 'hanmac-division-1', 'Concurrent public moves serialize safely; loading request rejects without losing data', { outcomes: simultaneous.outcomes, status: simultaneous.status, valid: simultaneous.valid });
measurements.push({ section: 'lifecycle', state: await snapshot() });
}
if (run('touch')) {
await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
await page.evaluate(() => {
window.touchPointers = [];
for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'gotpointercapture', 'lostpointercapture']) {
window.addEventListener(type, event => window.touchPointers.push({ type, pointerType: event.pointerType, isPrimary: event.isPrimary,
x: event.clientX, y: event.clientY, editing: window.shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging,
feedback: window.shadow.querySelector('.hm-orgchart-move-feedback').textContent }));
}
});
const from = await employee('virtual-006').boundingBox(), to = await title('hanmac-division-1').boundingBox();
const start = { x: from.x + from.width / 2, y: from.y + from.height / 2 }, end = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
const session = await page.context().newCDPSession(page);
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [start] });
for (let step = 1; step <= 8; step++) {
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start.x + (end.x - start.x) * step / 8, y: start.y + (end.y - start.y) * step / 8 }] });
}
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await ready();
const touch = await page.evaluate(() => ({ pointers: window.touchPointers,
organization: window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId,
events: window.bridgeEvents, dialogs: window.shadow.querySelectorAll('dialog[open]').length }));
check(touch.pointers.some(event => event.pointerType === 'touch' && event.isPrimary)
&& touch.organization === 'hanmac-division-1' && touch.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented) && touch.dialogs === 0,
'Synthetic browser touch input generates primary touch pointer, moves card and suppresses Dialog', touch);
measurements.push({ section: 'touch', state: touch, realDevice: 'NOT VERIFIED' });
await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }); await session.detach();
}
if (run('touch-pan')) {
await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready();
const setup = await page.evaluate(() => {
const viewport = window.shadow.querySelector('.hm-orgchart-viewport'), box = viewport.getBoundingClientRect();
const left = box.left + 24, right = Math.min(box.right - 72, innerWidth - 72), top = box.top + 24, bottom = Math.min(box.bottom - 72, innerHeight - 72);
for (let y = top; y <= bottom; y += 40) {
for (let x = left; x <= right; x += 40) {
const hit = window.shadow.elementFromPoint(x, y);
if (hit && viewport.contains(hit) && !hit.closest('button,a,.hm-orgchart-employee,.node')) {
window.touchPanPointers = [];
viewport.addEventListener('pointerdown', event => window.touchPanPointers.push({ pointerType: event.pointerType, isPrimary: event.isPrimary }));
return { point: { x, y }, sourceClass: String(hit.className), before: window.chart.getViewportState(), moves: window.moves.length };
}
}
}
throw new Error('No safe visible blank point for touch Pan');
});
const session = await page.context().newCDPSession(page);
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [setup.point] });
for (let step = 1; step <= 8; step++) {
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: setup.point.x + 40 * step / 8, y: setup.point.y + 30 * step / 8 }] });
}
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await settle();
const after = await page.evaluate(() => ({ viewport: window.chart.getViewportState(), moves: window.moves.length,
pointers: window.touchPanPointers, editing: window.shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging,
panDragging: window.shadow.querySelector('.hm-orgchart-viewport').dataset.dragging }));
check(after.pointers.some(event => event.pointerType === 'touch' && event.isPrimary)
&& Math.abs(after.viewport.x - setup.before.x - 40) < 1 && Math.abs(after.viewport.y - setup.before.y - 30) < 1
&& after.viewport.scale === setup.before.scale && after.moves === setup.moves && !after.editing && !after.panDragging,
'Synthetic touch blank-space Pan translates forest, retains scale and triggers no card move', { setup, after });
measurements.push({ section: 'touch-pan', setup, after, realDevice: 'NOT VERIFIED' });
await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }); await session.detach();
}
if (run('fixed')) {
await fresh(); await page.evaluate(() => window.mountChart('fixed')); await ready();
const fixed = await page.evaluate(async () => {
const before = window.shadow.querySelector('.hm-orgchart-stage')?.outerHTML;
let rejection;
try { await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }); }
catch (error) { rejection = error.message; }
return { rejection, unchanged: before === window.shadow.querySelector('.hm-orgchart-stage')?.outerHTML,
expanded: window.shadow.querySelectorAll('.hm-orgchart-expanded-toggle,.hm-orgchart-move-feedback,[data-edit-source],[data-drop-state]').length,
draggable: window.shadow.querySelectorAll('[draggable="true"]').length, status: window.chart.getState().status, data: window.chart.getData(), events: window.moves.length };
});
check(fixed.rejection?.includes('Auto mode') && fixed.unchanged && fixed.status === 'ready' && fixed.events === 0, 'Fixed public handle rejects editing and retains rendered stage', fixed);
check(fixed.expanded === 0 && fixed.draggable === 0 && fixed.data === undefined, 'Fixed has no editing controls, native drag or Auto browser-state API');
}
check(errors.length === 0, 'Vanilla Core change-only verification has no console/page errors', errors);
} catch (error) {
check(false, 'Unexpected verification error', { message: error.message, stack: error.stack });
await page.screenshot({ path: path.join(output, `vanilla-error${suffix}.png`), fullPage: true }).catch(() => {});
} finally {
await browser.close();
const completedAt = new Date().toISOString();
const counts = { PASS: results.filter(result => result.status === 'PASS').length, FAIL: results.filter(result => result.status === 'FAIL').length };
await fs.writeFile(path.join(output, `core-browser${suffix}.json`), JSON.stringify({ startedAt, completedAt, origin, counts, results, measurements, errors }, null, 2));
console.log(JSON.stringify({ counts, origin, output: `test-results/org-chart-p9-3/core-browser${suffix}.json` }));
if (counts.FAIL || errors.length) process.exitCode = 1;
}