372 lines
34 KiB
JavaScript
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;
|
|
}
|