feat(org-chart): implement expandable layout and multi-company navigation

Integrate P5-1B through P5-4 shared Core layout, collapse, zoom/pan/fit, company roots and keyboard accessibility. Include React/HTML examples, virtual 20/100/300 employee fixtures, focused checks and the additive data contract.
This commit is contained in:
shkim4 committed 2026-10-12 21:59:09 +09:00
1 parent a92913d6bc
commit 064517ec46
30 files changed
+7532 -183

No files matched your search

@@ -0,0 +1,48 @@
import fs from 'node:fs/promises';
const companies = [
['hanmac', '한맥기술', '회장'], ['saman', '삼안', '사업총괄'], ['jangheon-industry', '장헌산업', '본부장'],
['jangheon', '장헌', '대표이사'], ['ptc', '피티씨', '총괄책임자'], ['halla', '한라산업개발', '사장'], ['baron', '바론컨설턴트', null],
];
for (const [size, count, companyCount, depth] of [['small', 20, 3, 2], ['medium', 100, 5, 3], ['large', 300, 7, 4]]) {
const organizations = [], employees = [], affiliations = [];
const addEmployee = (org, position, name) => {
const id = `virtual-${String(employees.length + 1).padStart(3, '0')}`;
employees.push({ id, organizationId: org.id, affiliationId: org.companyId, name: name ?? (employees.length === 5 ? '홍길동' : `가상직원 ${String(employees.length + 1).padStart(3, '0')}`), position, jobTitle: null, profileImage: null, workStatus: 'active' });
return id;
};
for (const [index, [companyId, companyName, position]] of companies.slice(0, companyCount).entries()) {
affiliations.push({ id: companyId, name: companyName });
const org = (suffix, parentId, name, type, order) => {
const node = { id: `${companyId}-${suffix}`, companyId, parentId, name, type, leaderId: null, displayOrder: order };
organizations.push(node); return node;
};
const root = org('root', null, `${companyName} · 가상 조직`, 'root', index);
if (position) root.leaderId = addEmployee(root, position, `가상책임자 ${index + 1}`);
for (let branch = 0; branch < 2; branch++) {
const division = org(`division-${branch}`, root.id, `가상 ${branch ? '지원' : '사업'}본부`, 'division', branch);
if (count > 20) division.leaderId = addEmployee(division, '본부장');
const office = org(`office-${branch}`, division.id, `가상 ${branch + 1}실`, 'office', 0);
if (depth >= 3) {
const cell = org(`cell-${branch}`, office.id, `가상 ${branch + 1}셀`, 'cell', 0);
if (depth >= 4) {
org(`team-${branch}`, cell.id, `가상 ${branch + 1}팀`, 'team', 0);
if (branch === 0) org('team-sibling', cell.id, '가상 협력팀', 'team', 1);
}
}
if (count > 20 && branch === 0) org('office-sibling', division.id, '가상 계획실', 'office', 1);
}
}
// Unequal groups: deterministic weighted membership; no random or live data.
const pool = organizations.filter(org => org.parentId !== null);
const weighted = pool.flatMap((org, index) => Array.from({ length: 1 + index % 4 }, () => org));
let cursor = 0;
while (employees.length < count) {
const org = weighted[cursor++ % weighted.length];
addEmployee(org, ['사원', '선임', '책임', '수석'][cursor % 4]);
}
const data = { schemaVersion: '0.1-draft', organizations, employees, affiliations, relations: [],
presentation: { title: '확장형 조직도 · 가상 데이터', description: '실제 직원 정보가 아닌 고정된 테스트 데이터입니다.', hubText: null,
groups: { showEmployees: true, showSummary: false, showDispatched: false, showOnLeave: false, showAffiliations: false }, organizationOrder: [] },
metadata: { asOfDate: '2026-10-12', dataVersion: `p5-23-${size}-${count}`, updateNote: '가상 데이터 · 운영 정보 아님' } };
await fs.writeFile(new URL(`./data-expanded-${size}.json`, import.meta.url), JSON.stringify(data, null, 2) + '\n');
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+375
View File
@@ -0,0 +1,375 @@
{
"schemaVersion": "0.1-draft",
"organizations": [
{
"id": "hanmac-root",
"companyId": "hanmac",
"parentId": null,
"name": "한맥기술 · 가상 조직",
"type": "root",
"leaderId": "virtual-001",
"displayOrder": 0
},
{
"id": "hanmac-division-0",
"companyId": "hanmac",
"parentId": "hanmac-root",
"name": "가상 사업본부",
"type": "division",
"leaderId": null,
"displayOrder": 0
},
{
"id": "hanmac-office-0",
"companyId": "hanmac",
"parentId": "hanmac-division-0",
"name": "가상 1실",
"type": "office",
"leaderId": null,
"displayOrder": 0
},
{
"id": "hanmac-division-1",
"companyId": "hanmac",
"parentId": "hanmac-root",
"name": "가상 지원본부",
"type": "division",
"leaderId": null,
"displayOrder": 1
},
{
"id": "hanmac-office-1",
"companyId": "hanmac",
"parentId": "hanmac-division-1",
"name": "가상 2실",
"type": "office",
"leaderId": null,
"displayOrder": 0
},
{
"id": "saman-root",
"companyId": "saman",
"parentId": null,
"name": "삼안 · 가상 조직",
"type": "root",
"leaderId": "virtual-002",
"displayOrder": 1
},
{
"id": "saman-division-0",
"companyId": "saman",
"parentId": "saman-root",
"name": "가상 사업본부",
"type": "division",
"leaderId": null,
"displayOrder": 0
},
{
"id": "saman-office-0",
"companyId": "saman",
"parentId": "saman-division-0",
"name": "가상 1실",
"type": "office",
"leaderId": null,
"displayOrder": 0
},
{
"id": "saman-division-1",
"companyId": "saman",
"parentId": "saman-root",
"name": "가상 지원본부",
"type": "division",
"leaderId": null,
"displayOrder": 1
},
{
"id": "saman-office-1",
"companyId": "saman",
"parentId": "saman-division-1",
"name": "가상 2실",
"type": "office",
"leaderId": null,
"displayOrder": 0
},
{
"id": "jangheon-industry-root",
"companyId": "jangheon-industry",
"parentId": null,
"name": "장헌산업 · 가상 조직",
"type": "root",
"leaderId": "virtual-003",
"displayOrder": 2
},
{
"id": "jangheon-industry-division-0",
"companyId": "jangheon-industry",
"parentId": "jangheon-industry-root",
"name": "가상 사업본부",
"type": "division",
"leaderId": null,
"displayOrder": 0
},
{
"id": "jangheon-industry-office-0",
"companyId": "jangheon-industry",
"parentId": "jangheon-industry-division-0",
"name": "가상 1실",
"type": "office",
"leaderId": null,
"displayOrder": 0
},
{
"id": "jangheon-industry-division-1",
"companyId": "jangheon-industry",
"parentId": "jangheon-industry-root",
"name": "가상 지원본부",
"type": "division",
"leaderId": null,
"displayOrder": 1
},
{
"id": "jangheon-industry-office-1",
"companyId": "jangheon-industry",
"parentId": "jangheon-industry-division-1",
"name": "가상 2실",
"type": "office",
"leaderId": null,
"displayOrder": 0
}
],
"employees": [
{
"id": "virtual-001",
"organizationId": "hanmac-root",
"affiliationId": "hanmac",
"name": "가상책임자 1",
"position": "회장",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-002",
"organizationId": "saman-root",
"affiliationId": "saman",
"name": "가상책임자 2",
"position": "사업총괄",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-003",
"organizationId": "jangheon-industry-root",
"affiliationId": "jangheon-industry",
"name": "가상책임자 3",
"position": "본부장",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-004",
"organizationId": "hanmac-division-0",
"affiliationId": "hanmac",
"name": "가상직원 004",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-005",
"organizationId": "hanmac-office-0",
"affiliationId": "hanmac",
"name": "가상직원 005",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-006",
"organizationId": "hanmac-office-0",
"affiliationId": "hanmac",
"name": "홍길동",
"position": "수석",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-007",
"organizationId": "hanmac-division-1",
"affiliationId": "hanmac",
"name": "가상직원 007",
"position": "사원",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-008",
"organizationId": "hanmac-division-1",
"affiliationId": "hanmac",
"name": "가상직원 008",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-009",
"organizationId": "hanmac-division-1",
"affiliationId": "hanmac",
"name": "가상직원 009",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-010",
"organizationId": "hanmac-office-1",
"affiliationId": "hanmac",
"name": "가상직원 010",
"position": "수석",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-011",
"organizationId": "hanmac-office-1",
"affiliationId": "hanmac",
"name": "가상직원 011",
"position": "사원",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-012",
"organizationId": "hanmac-office-1",
"affiliationId": "hanmac",
"name": "가상직원 012",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-013",
"organizationId": "hanmac-office-1",
"affiliationId": "hanmac",
"name": "가상직원 013",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-014",
"organizationId": "saman-division-0",
"affiliationId": "saman",
"name": "가상직원 014",
"position": "수석",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-015",
"organizationId": "saman-office-0",
"affiliationId": "saman",
"name": "가상직원 015",
"position": "사원",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-016",
"organizationId": "saman-office-0",
"affiliationId": "saman",
"name": "가상직원 016",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-017",
"organizationId": "saman-division-1",
"affiliationId": "saman",
"name": "가상직원 017",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-018",
"organizationId": "saman-division-1",
"affiliationId": "saman",
"name": "가상직원 018",
"position": "수석",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-019",
"organizationId": "saman-division-1",
"affiliationId": "saman",
"name": "가상직원 019",
"position": "사원",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "virtual-020",
"organizationId": "saman-office-1",
"affiliationId": "saman",
"name": "가상직원 020",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
}
],
"affiliations": [
{
"id": "hanmac",
"name": "한맥기술"
},
{
"id": "saman",
"name": "삼안"
},
{
"id": "jangheon-industry",
"name": "장헌산업"
}
],
"relations": [],
"presentation": {
"title": "확장형 조직도 · 가상 데이터",
"description": "실제 직원 정보가 아닌 고정된 테스트 데이터입니다.",
"hubText": null,
"groups": {
"showEmployees": true,
"showSummary": false,
"showDispatched": false,
"showOnLeave": false,
"showAffiliations": false
},
"organizationOrder": []
},
"metadata": {
"asOfDate": "2026-10-12",
"dataVersion": "p5-23-small-20",
"updateNote": "가상 데이터 · 운영 정보 아님"
}
}
+320 -27
View File
@@ -1,43 +1,336 @@
{
"schemaVersion": "0.1-draft",
"organizations": [
{ "id": "general-planning", "parentId": null, "name": "총괄기획실", "type": "root", "leaderId": "expanded-representative", "displayOrder": 0 },
{ "id": "business-management", "parentId": "general-planning", "name": "사업관리", "type": "office", "leaderId": "expanded-leader-01", "displayOrder": 0 },
{ "id": "business-planning", "parentId": "general-planning", "name": "경영기획", "type": "office", "leaderId": "expanded-leader-02", "displayOrder": 1 },
{ "id": "talent-growth", "parentId": "general-planning", "name": "인재성장", "type": "office", "leaderId": "expanded-leader-03", "displayOrder": 2 }
{
"id": "general-planning",
"parentId": null,
"name": "총괄기획실",
"type": "root",
"leaderId": "expanded-representative",
"displayOrder": 0
},
{
"id": "business-management",
"parentId": "general-planning",
"name": "사업관리",
"type": "office",
"leaderId": "expanded-leader-01",
"displayOrder": 0
},
{
"id": "business-planning",
"parentId": "general-planning",
"name": "경영기획",
"type": "office",
"leaderId": "expanded-leader-02",
"displayOrder": 1
},
{
"id": "talent-growth",
"parentId": "general-planning",
"name": "인재성장",
"type": "office",
"leaderId": "expanded-leader-03",
"displayOrder": 2
},
{
"id": "business-control",
"parentId": "business-management",
"name": "사업관리 셀",
"type": "cell",
"leaderId": "expanded-cell-leader-01",
"displayOrder": 0
},
{
"id": "project-support",
"parentId": "business-management",
"name": "사업지원 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 1
},
{
"id": "finance",
"parentId": "business-planning",
"name": "재무 셀",
"type": "cell",
"leaderId": "expanded-cell-leader-02",
"displayOrder": 2
},
{
"id": "planning-support",
"parentId": "business-planning",
"name": "기획지원 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 3
},
{
"id": "people-support",
"parentId": "talent-growth",
"name": "성장지원 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 4
},
{
"id": "project-review",
"parentId": "business-control",
"name": "사업검토 팀",
"type": "cell",
"leaderId": "expanded-team-leader-01",
"displayOrder": 5
},
{
"id": "review-support",
"parentId": "project-review",
"name": "검토지원 파트",
"type": "cell",
"leaderId": null,
"displayOrder": 6
}
],
"employees": [
{ "id": "expanded-representative", "organizationId": "general-planning", "affiliationId": "hanmac", "name": "홍길동", "position": "대표이사", "jobTitle": "대표이사", "profileImage": null, "workStatus": "active" },
{ "id": "expanded-leader-01", "organizationId": "business-management", "affiliationId": "hanmac", "name": "이도윤", "position": "수석", "jobTitle": "팀장", "profileImage": null, "workStatus": "active" },
{ "id": "expanded-employee-01", "organizationId": "business-management", "affiliationId": "saman", "name": "김하린", "position": "책임", "jobTitle": null, "profileImage": null, "workStatus": "active" },
{ "id": "expanded-employee-02", "organizationId": "business-management", "affiliationId": "jangheon-industry", "name": "박서준", "position": "선임", "jobTitle": null, "profileImage": null, "workStatus": "active" },
{ "id": "expanded-leader-02", "organizationId": "business-planning", "affiliationId": "jangheon", "name": "정지우", "position": "수석", "jobTitle": "팀장", "profileImage": null, "workStatus": "active" },
{ "id": "expanded-employee-03", "organizationId": "business-planning", "affiliationId": "ptc", "name": "최유진", "position": "책임", "jobTitle": null, "profileImage": null, "workStatus": "active" },
{ "id": "expanded-employee-04", "organizationId": "business-planning", "affiliationId": "hanmac", "name": "윤서아", "position": "연구", "jobTitle": null, "profileImage": null, "workStatus": "active" },
{ "id": "expanded-leader-03", "organizationId": "talent-growth", "affiliationId": "halla", "name": "강민준", "position": "수석", "jobTitle": "팀장", "profileImage": null, "workStatus": "active" },
{ "id": "expanded-employee-05", "organizationId": "talent-growth", "affiliationId": "baron", "name": "한예린", "position": "책임", "jobTitle": null, "profileImage": null, "workStatus": "active" },
{ "id": "expanded-employee-06", "organizationId": "talent-growth", "affiliationId": "saman", "name": "오하준", "position": "선임", "jobTitle": null, "profileImage": null, "workStatus": "active" }
{
"id": "expanded-representative",
"organizationId": "general-planning",
"affiliationId": "hanmac",
"name": "홍길동",
"position": "대표이사",
"jobTitle": "대표이사",
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-leader-01",
"organizationId": "business-management",
"affiliationId": "hanmac",
"name": "이도윤",
"position": "수석",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-employee-01",
"organizationId": "business-management",
"affiliationId": "saman",
"name": "김하린",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-employee-02",
"organizationId": "business-management",
"affiliationId": "jangheon-industry",
"name": "박서준",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-leader-02",
"organizationId": "business-planning",
"affiliationId": "jangheon",
"name": "정지우",
"position": "수석",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-employee-03",
"organizationId": "business-planning",
"affiliationId": "ptc",
"name": "최유진",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-employee-04",
"organizationId": "business-planning",
"affiliationId": "hanmac",
"name": "윤서아",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-leader-03",
"organizationId": "talent-growth",
"affiliationId": "halla",
"name": "강민준",
"position": "수석",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-employee-05",
"organizationId": "talent-growth",
"affiliationId": "baron",
"name": "한예린",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-employee-06",
"organizationId": "talent-growth",
"affiliationId": "saman",
"name": "오하준",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-cell-leader-01",
"organizationId": "business-control",
"affiliationId": "hanmac",
"name": "김민준",
"position": "팀장",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-cell-leader-02",
"organizationId": "finance",
"affiliationId": "hanmac",
"name": "김민준",
"position": "팀장",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-team-leader-01",
"organizationId": "project-review",
"affiliationId": "hanmac",
"name": "김민준",
"position": "팀장",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-nested-1",
"organizationId": "business-control",
"affiliationId": "hanmac",
"name": "서지우",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-nested-2",
"organizationId": "business-control",
"affiliationId": "hanmac",
"name": "서지우",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-nested-3",
"organizationId": "finance",
"affiliationId": "hanmac",
"name": "서지우",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-nested-4",
"organizationId": "project-review",
"affiliationId": "hanmac",
"name": "서지우",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "expanded-nested-5",
"organizationId": "review-support",
"affiliationId": "hanmac",
"name": "서지우",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
}
],
"affiliations": [
{ "id": "hanmac", "name": "(주)한맥기술" },
{ "id": "saman", "name": "(주)삼안" },
{ "id": "jangheon", "name": "(주)장헌" },
{ "id": "jangheon-industry", "name": "장헌산업" },
{ "id": "ptc", "name": "PTC" },
{ "id": "halla", "name": "한라산업개발" },
{ "id": "baron", "name": "바론컨설턴트" }
{
"id": "hanmac",
"name": "(주)한맥기술"
},
{
"id": "saman",
"name": "(주)삼안"
},
{
"id": "jangheon",
"name": "(주)장헌"
},
{
"id": "jangheon-industry",
"name": "장헌산업"
},
{
"id": "ptc",
"name": "PTC"
},
{
"id": "halla",
"name": "한라산업개발"
},
{
"id": "baron",
"name": "바론컨설턴트"
}
],
"relations": [],
"presentation": {
"title": "확장형 조직도 · P5-1A",
"description": "대표자와 1Depth 그룹 초기 표시 · 자동 계층 배치 미구현 · 가상 직원 정보",
"title": "확장형 조직도",
"description": "자동 계층 배치 · 가상 직원 정보",
"hubText": null,
"groups": { "showEmployees": true, "showSummary": false, "showDispatched": false, "showOnLeave": false, "showAffiliations": false },
"organizationOrder": ["general-planning", "business-management", "business-planning", "talent-growth"]
"groups": {
"showEmployees": true,
"showSummary": false,
"showDispatched": false,
"showOnLeave": false,
"showAffiliations": false
},
"organizationOrder": [
"general-planning",
"business-management",
"business-planning",
"talent-growth",
"business-control",
"project-support",
"finance",
"planning-support",
"people-support",
"project-review",
"review-support"
]
},
"metadata": {
"asOfDate": "2026-10-12",
"dataVersion": "p5-1a-expanded-initial-v1",
"updateNote": "P5-1A 초기 1Depth 구성 · 운영 HR 정보와 무관한 가상 데이터"
"dataVersion": "p5-1b-expanded-hierarchy-v1",
"updateNote": "P5-1B 최소 재귀 계층 데이터 · 운영 HR 정보와 무관"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { createOrgChart, JsonOrgChartDataSource } from './assets/index.js';
const chart = document.getElementById('chart');
const selector = document.getElementById('dataset');
const fieldset = document.getElementById('companies');
const error = document.getElementById('error');
const fixtures = Object.fromEntries(await Promise.all(['small', 'medium', 'large'].map(async size => [size, await (await fetch(`./data-expanded-${size}.json`)).json()])));
const connections = Object.fromEntries(fixtures.large.organizations.map((org, index) => [org.id, ['top', 'left', 'right'][index % 3]]));
const handle = createOrgChart({ container: chart, layoutMode: 'auto', displayMode: 'readOnly', dataSource: new JsonOrgChartDataSource(fixtures.small),
stylesheetUrls: { engine: './assets/engine.css', core: './assets/style.css' }, employeeConnections: connections,
companyTopNodes: { ptc: { title: '기술총괄', name: '가상책임자 5' } },
onStateChange: state => { error.textContent = state.status === 'error' ? '조직도 데이터 또는 스타일을 확인해 주세요.' : ''; } });
// Dev/example ownership only. Filtering is done by the public Core handle.
window.expandedHandle = handle;
async function selection() {
await handle.setSelectedCompanies([...fieldset.querySelectorAll('input:checked')].map(input => input.value));
}
function companies() {
fieldset.querySelectorAll('label').forEach(label => label.remove());
for (const company of fixtures[selector.value].affiliations) {
const label = document.createElement('label'), input = document.createElement('input');
input.type = 'checkbox'; input.checked = true; input.value = company.id;
input.id = `company-${company.id}`; label.htmlFor = input.id;
label.append(input, document.createTextNode(company.name));
input.addEventListener('change', () => { void selection(); }); fieldset.append(label);
}
}
selector.addEventListener('change', async () => {
companies(); await handle.setSelectedCompanies(); await handle.reload(new JsonOrgChartDataSource(fixtures[selector.value]));
});
document.getElementById('all').addEventListener('click', async () => { fieldset.querySelectorAll('input').forEach(input => { input.checked = true; }); await handle.setSelectedCompanies(); });
document.getElementById('none').addEventListener('click', async () => { fieldset.querySelectorAll('input').forEach(input => { input.checked = false; }); await selection(); });
companies(); await handle.mount();
window.addEventListener('pagehide', () => handle.destroy(), { once: true });
+9
View File
@@ -0,0 +1,9 @@
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; }
main { padding: 16px; min-width: 0; }
h1 { font-size: 22px; }
.controls, #companies { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
#companies { border: 0; padding: 16px 0; }
#companies label { display: flex; gap: 8px; align-items: center; }
#companies legend { padding: 8px 0; }
button, select { font: inherit; }
+6
View File
@@ -0,0 +1,6 @@
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,"><title>확장형 조직도 · 가상 데이터</title>
<link rel="stylesheet" href="./assets/hm-theme.css"><link rel="stylesheet" href="./expanded.css"></head>
<body><main><h1>확장형 조직도 · HTML Core 예시</h1><p>모든 직원은 고정된 가상 데이터입니다.</p>
<div class="controls"><label for="dataset">가상 데이터</label><select id="dataset"><option value="small">Small · 20명</option><option value="medium">Medium · 100명</option><option value="large">Large · 300명</option></select><button id="all" type="button">전체 회사</button><button id="none" type="button">선택 해제</button></div>
<fieldset id="companies"><legend>표시할 회사 · 복수 선택 가능</legend></fieldset><div id="chart"></div><p id="error" role="alert"></p></main><script type="module" src="./expanded-app.js"></script></body></html>
+2
View File
@@ -38,3 +38,5 @@ await writeFile(path.join(output, 'p1-fixture.js'), `window.OrgChartP1Fixture =
const p1App = await readFile(path.join(example, 'p1-app.js'), 'utf8');
await writeFile(path.join(output, 'p1-app-classic.js'), p1App.replace('export async function startP1', 'async function startP1'));
console.log(`OrgChart HTML example: ${output}`);
for (const file of ['expanded.html', 'expanded-app.js', 'expanded.css', 'data-expanded-small.json', 'data-expanded-medium.json', 'data-expanded-large.json'])
await cp(path.join(example, file), path.join(output, file));
+377
View File
@@ -0,0 +1,377 @@
import http from 'node:http';
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
// P5-1B focused integration suite. Preserve historical verification evidence.
// Copy the pre-change public Core dist to baseline-dist before rebuilding.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p5-1b');
const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6053';
const playwright = process.env.ORGCHART_PLAYWRIGHT_MODULE;
const { chromium } = await import(playwright ? pathToFileURL(playwright).href : 'playwright');
const fixedFixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
const expandedFixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded.json'), 'utf8'));
const presets = [
{ id: 'hanmac', name: '한맥기술', primary: '#CB2E28', secondary: ['#1D4289'] },
{ id: 'saman', name: '삼안', primary: '#F08300', secondary: ['#6D6C6C'] },
{ id: 'jangheon-industry', name: '장헌산업', primary: '#4580A6', secondary: ['#92BED4'] },
{ id: 'jangheon', name: '장헌', primary: '#1D418D', secondary: ['#AE232A'] },
{ id: 'ptc', name: '피티씨', primary: '#84338E', secondary: ['#F7AF00', '#0066B5'] },
{ id: 'halla', name: '한라산업개발', primary: '#0068B7', secondary: ['#231815'] },
{ id: 'baron', name: '바론컨설턴트', primary: '#0C367F', secondary: [] },
];
const results = [], measurements = [], errors = [], warnings = [];
const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const near = (a, b) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= 0.35;
const rgb = hex => `rgb(${[1, 3, 5].map(offset => parseInt(hex.slice(offset, offset + 2), 16)).join(', ')})`;
const expectedCompanyIds = presets.map(preset => preset.id);
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml', '.png': 'image/png' };
const shellHtml = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,"><style>@font-face{font-family:"Noto Sans KR";src:url("/fonts/NotoSansKR-variable.ttf");font-weight:100 900;font-style:normal}*{box-sizing:border-box}html,body{margin:0;width:100%;min-height:100%;font-family:"Noto Sans KR",sans-serif}#chart{width:100%;height:100vh;min-width:0}</style></head><body><main id="chart"></main></body></html>';
await fs.mkdir(output, { recursive: true });
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/__p51b') return response.writeHead(200, { 'Content-Type': 'text/html' }).end(shellHtml);
const pathname = decodeURIComponent(url.pathname);
const file = path.resolve(root, '.' + (pathname.startsWith('/fonts/') ? '/public' + pathname : pathname));
const relative = path.relative(root, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
const bytes = await fs.readFile(file);
response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' });
response.end(bytes);
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
async function newPage(name, viewport = { width: 1440, height: 900 }) {
const page = await browser.newPage({ viewport });
page.on('pageerror', error => errors.push({ name, type: 'pageerror', message: error.message }));
page.on('console', message => {
if (message.type() === 'error') errors.push({ name, type: 'console', message: message.text() });
if (message.type() === 'warning') warnings.push({ name, message: message.text() });
});
return page;
}
async function ready(page, mode) {
await page.waitForFunction(expected => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
const content = shadow?.querySelector(expected === 'auto' ? '.hm-orgchart-expanded' : '.hm-orgchart-composition');
return content?.querySelector('.hm-orgchart-employee') && [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => link.sheet);
}, mode, { timeout: 25000 });
await page.evaluate(async () => {
await document.fonts.load('600 24px "Noto Sans KR"', '홍 길 동');
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const images = [...shadow.querySelectorAll('.hm-orgchart-employee img')];
for (const image of images) image.loading = 'eager';
await Promise.all(images.map(image => image.decode().catch(() => {})));
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
}
async function mount(page, { baseline = false, fixture = expandedFixture, options = {} } = {}) {
await page.goto(`${origin}/__p51b`);
await page.evaluate(async ({ base, fixture, options }) => {
window.hmModule = await import(`${base}/index.js`);
window.fixture = structuredClone(fixture);
window.handle = window.hmModule.createOrgChart({
container: document.getElementById('chart'),
dataSource: new window.hmModule.JsonOrgChartDataSource(fixture),
stylesheetUrls: { engine: `${base}/engine.css`, core: `${base}/style.css` },
...options,
});
await window.handle.mount();
}, { base: `${origin}/${baseline ? 'test-results/org-chart-p5-1b/baseline-dist' : 'packages/org-chart/dist'}`, fixture, options });
await ready(page, options.layoutMode === 'auto' ? 'auto' : 'fixed');
return page.evaluate(() => window.handle.getState());
}
async function measure(page) {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const stage = shadow.querySelector('.hm-orgchart-stage');
const base = stage?.getBoundingClientRect() ?? shadow.querySelector('.hm-orgchart-expanded').getBoundingClientRect();
const scale = stage ? base.width / stage.offsetWidth : 1;
const box = element => {
const bounds = element.getBoundingClientRect();
return { x: (bounds.x - base.x) / scale, y: (bounds.y - base.y) / scale, width: bounds.width / scale, height: bounds.height / scale };
};
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({
key: `${card.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? 'tree'}:${card.dataset.employeeId}`,
id: card.dataset.employeeId, kind: card.dataset.cardKind, size: card.dataset.cardAssetSize ?? null,
box: box(card), asset: card.querySelector('.hm-orgchart-card-background')?.src ?? null,
logo: card.querySelector('.hm-orgchart-affiliation-mark img')?.src ?? null,
name: card.querySelector('.hm-orgchart-person-name')?.textContent,
role: card.querySelector('.hm-orgchart-person-role')?.textContent,
font: getComputedStyle(card.querySelector('.hm-orgchart-person-name')).fontFamily,
badgeCount: card.querySelectorAll('.hm-orgchart-job-title,.hm-orgchart-status-label,.hm-orgchart-person-attributes').length,
placeholder: card.dataset.photoState,
}));
const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => {
const heading = node.querySelector('.hm-orgchart-organization-name');
const style = getComputedStyle(heading);
return { id: node.dataset.organizationId, companyId: node.dataset.companyId ?? null,
depth: Number(node.dataset.organizationDepth), text: heading.textContent, box: box(heading),
backgroundColor: style.backgroundColor, backgroundImage: style.backgroundImage,
primary: node.style.getPropertyValue('--hm-orgchart-expanded-primary'),
secondary: [...node.style].filter(property => property.startsWith('--hm-orgchart-expanded-secondary-')).map(property => node.style.getPropertyValue(property)),
};
});
const collisions = [];
for (let index = 0; index < cards.length; index++) for (const other of cards.slice(index + 1)) {
const a = cards[index].box, b = other.box;
if (Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.5
&& Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.5) collisions.push([cards[index].key, other.key]);
}
const viewport = shadow.querySelector('.hm-orgchart-viewport');
const expanded = shadow.querySelector('.hm-orgchart-expanded');
const summaries = [...shadow.querySelectorAll('[data-component="PositionSummary"],[data-component="AffiliationSummary"],[data-component="StatusSummary"]')].map(element => ({ kind: element.dataset.component, text: element.textContent }));
return { scale, cards, nodes, summaries, collisions,
hostCount: document.querySelectorAll('.hm-orgchart-host').length,
compositionCount: shadow.querySelectorAll('.hm-orgchart-composition,.hm-orgchart-expanded').length,
controls: shadow.querySelectorAll('.hm-orgchart-toggle,.zoom-controls,.pan-controls,.minimap').length,
layoutStatus: expanded?.dataset.layoutStatus ?? null,
phaseText: expanded?.textContent ?? '',
viewport: viewport ? { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX } : null,
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
};
});
}
function compareFixed(before, after, name) {
check(JSON.stringify(before.cards.map(({ box, ...rest }) => rest)) === JSON.stringify(after.cards.map(({ box, ...rest }) => rest)), `${name}: card assets/content/typography/placeholder unchanged`);
check(before.cards.length === after.cards.length && before.cards.every((card, index) => ['x', 'y', 'width', 'height'].every(key => near(card.box[key], after.cards[index].box[key]))), `${name}: fixed card coordinates and dimensions unchanged`);
check(before.nodes.length === after.nodes.length && before.nodes.every((node, index) => {
const next = after.nodes[index];
return node.id === next.id && node.depth === next.depth && node.text === next.text
&& node.backgroundColor === next.backgroundColor && node.backgroundImage === next.backgroundImage
&& ['x', 'y', 'width', 'height'].every(key => near(node.box[key], next.box[key]));
}), `${name}: hierarchy headers and Workdesk gradients unchanged`);
check(JSON.stringify(before.summaries) === JSON.stringify(after.summaries), `${name}: summary counts unchanged`);
check(after.controls === 0 && after.compositionCount === 1 && after.layoutStatus === null, `${name}: fixed read-only renderer retained`);
}
async function popup(page, name, id) {
const card = page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`).first();
await card.focus();
await card.press('Enter');
await page.locator('dialog[open]').waitFor();
const data = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const dialog = shadow.querySelector('dialog[open]');
return { employeeId: dialog.dataset.employeeId, text: dialog.textContent, count: shadow.querySelectorAll('dialog[open]').length, activeInside: dialog.contains(shadow.activeElement) };
});
check(data.employeeId === id && data.count === 1 && data.activeInside, `${name}: keyboard opens shared employee popup`, data);
await page.keyboard.press('Tab');
check(await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
return shadow.querySelector('dialog[open]').contains(shadow.activeElement);
}), `${name}: popup Tab focus remains inside`);
await page.keyboard.press('Escape');
await page.locator('dialog[open]').waitFor({ state: 'detached' });
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), `${name}: Escape restores initiating card focus`);
return data.text;
}
async function scenario(name, run) {
try { await run(); }
catch (error) { check(false, `${name}: verification completed`, { message: error.message, stack: error.stack }); }
}
async function geometry(page) {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const rect = element => { const r = element.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,cx:r.x+r.width/2}; };
const pseudo = (element, name) => {
const s = getComputedStyle(element, name);
return {content:s.content,top:s.top,bottom:s.bottom,height:s.height,width:s.width,border:s.borderTopWidth,color:s.backgroundColor};
};
const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => {
const hierarchy = node.parentElement;
const childRow = [...hierarchy.children].find(item => item.classList.contains('nodes'));
const parent = hierarchy.parentElement.closest('.hierarchy')?.querySelector(':scope > .node');
return {id:node.dataset.organizationId,parentId:node.dataset.parentId,domParent:parent?.dataset.organizationId ?? '',depth:Number(node.dataset.depth),
node:rect(node),article:rect(node.querySelector('.hm-orgchart-organization')),subtree:rect(hierarchy),children:childRow ? rect(childRow) : null,
upper:pseudo(node,'::before'),lower:pseudo(node,'::after'),bar:pseudo(hierarchy,'::before')};
});
return {nodes,tree:rect(shadow.querySelector('.hm-orgchart-forest'))};
});
}
async function assertHierarchy(page, name, fixture) {
const value=await measure(page), layout=await geometry(page);
measurements.push({name,...value,layout});
check(value.nodes.length===fixture.organizations.length,`${name}: every organization rendered`);
const expected = fixture.employees.filter(employee => fixture.presentation?.groups.showEmployees !== false || fixture.organizations.some(org=>org.leaderId===employee.id));
check(value.cards.length===expected.length && new Set(value.cards.map(c=>c.id)).size===value.cards.length,`${name}: employees once, no missing or duplicate leaders`);
check(value.collisions.length===0,`${name}: card collision count zero`,value.collisions);
check(value.controls===0 && value.layoutStatus==='hierarchical' && value.scale===1,`${name}: expanded hierarchy without scale/collapse/zoom/pan controls`);
for (const depth of new Set(layout.nodes.map(n=>n.depth))) {
const row=layout.nodes.filter(n=>n.depth===depth);
check(row.every(n=>near(n.node.y,row[0].node.y)),`${name}: depth ${depth} horizontal alignment`);
}
for (const n of layout.nodes) {
check(n.parentId===n.domParent,`${name}: ${n.id} actual DOM parent matches domain parent`);
if(n.parentId) {
const parent=layout.nodes.find(p=>p.id===n.parentId);
check(parent && n.node.y>parent.article.bottom,`${name}: ${n.id} below its parent content`);
check(n.upper.content!=='none' && parseFloat(n.upper.height)>0,`${name}: ${n.id} upward connector exists`);
}
if(n.children) {
check(near(n.node.cx,n.children.cx),`${name}: ${n.id} centered above own subtree`);
check(n.lower.content!=='none' && parseFloat(n.lower.height)>0,`${name}: ${n.id} downward connector`);
} else check(n.lower.content==='none',`${name}: ${n.id} leaf has no downward connector`);
}
for (let i=0;i<layout.nodes.length;i++) for(const other of layout.nodes.slice(i+1)) {
const a=layout.nodes[i],b=other;
if(a.parentId===b.parentId && a.depth===b.depth)
check(Math.min(a.subtree.right,b.subtree.right)-Math.max(a.subtree.x,b.subtree.x)<=0.5,`${name}: sibling subtrees ${a.id}/${b.id} disjoint`);
}
check(value.cards.every(c=>c.name==='홍 길 동' && c.font.includes('Noto Sans KR') && !c.asset),`${name}: Auto content, shared font, no Fixed SVG assets`);
const rep=value.cards.filter(c=>c.kind==='topLeader');
check(rep.length===1 && rep[0].badgeCount===0 && rep[0].role==='대표이사',`${name}: representative position and zero badges`);
check(value.document.scrollWidth<=value.document.clientWidth+1,`${name}: no document horizontal overflow`,value.document);
return {value,layout};
}
try {
if(process.env.ORGCHART_VERIFY_RETRY === 'overflow') {
const page=await newPage('overflow-recheck',{width:1920,height:1080});
try {
await mount(page,{options:{layoutMode:'auto',displayMode:'readOnly'}});
const before=await geometry(page);
const grown=structuredClone(expandedFixture);
for(let i=0;i<12;i++) grown.employees.push({id:'many-'+i,organizationId:'business-management',affiliationId:'hanmac',name:'김지우',position:'선임',jobTitle:null,profileImage:null,workStatus:'active'});
for(let i=0;i<8;i++) grown.organizations.push({id:'new-sibling-'+i,parentId:'business-planning',name:'추가 셀 '+i,type:'cell',leaderId:null,displayOrder:100+i});
await page.evaluate(async data=>window.handle.update(data),grown);await ready(page,'auto');
const after=await assertHierarchy(page,'Wider updated tree',grown);
check(after.layout.tree.width>before.tree.width && after.layout.tree.height>before.tree.height,'Organization/staff growth increases natural content width and height');
check(after.value.viewport.scrollWidth>after.value.viewport.clientWidth && after.value.viewport.scrollHeight>after.value.viewport.clientHeight,'Large tree has horizontal and vertical inner overflow');
const scroll=await page.evaluate(()=>{const v=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-viewport');v.scrollLeft=v.scrollWidth;v.scrollTop=v.scrollHeight;return {x:v.scrollLeft,y:v.scrollTop};});
check(scroll.x>0&&scroll.y>0,'Both overflow axes scroll to far content');
await page.screenshot({path:path.join(output,'vanilla-overflow-scrolled.png'),fullPage:true});
await page.goto(storyOrigin+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(page,'auto');
for(const width of [1280,1440]) {
await page.setViewportSize({width,height:900});await ready(page,'auto');
const value=await measure(page),layout=await geometry(page);
check(value.document.scrollWidth<=value.document.clientWidth+1 && (layout.nodes[0].subtree.width<=value.viewport.clientWidth || value.viewport.scrollWidth>value.viewport.clientWidth),'React '+width+' wide tree accessible with horizontal scroll');
}
// Full-tree capture: expand only this test's viewport height. All cards
// remain actual React DOM; the normal screenshots retain scroll policy.
await page.setViewportSize({width:1920,height:2000});await ready(page,'auto');
await page.screenshot({path:path.join(output,'storybook-expanded-full-tree.png'),fullPage:true});
} finally {await page.close();}
} else {
await scenario('Fixed minimal regression',async()=>{
const page=await newPage('fixed-regression');
try {
await mount(page,{baseline:true,fixture:fixedFixture,options:{displayMode:'readOnly'}});
const before=await measure(page);
const beforePopup=await popup(page,'Fixed baseline','mock-leader-00');
await mount(page,{fixture:fixedFixture,options:{displayMode:'readOnly'}});
const after=await measure(page);
compareFixed(before,after,'Fixed baseline/current');
check(await popup(page,'Fixed current','mock-leader-00')===beforePopup,'Fixed popup unchanged');
await page.screenshot({path:path.join(output,'fixed-current-1440.png'),fullPage:true});
} finally {await page.close();}
});
let vanilla;
await scenario('Auto Core hierarchy and updates',async()=>{
const page=await newPage('vanilla-auto',{width:1920,height:1080});
try {
await mount(page,{options:{layoutMode:'auto',displayMode:'readOnly',organizationLinks:{'business-management':'#business'}}});
vanilla=await assertHierarchy(page,'Vanilla initial',expandedFixture);
check(vanilla.layout.nodes.some(n=>n.depth===4),'4Depth recursive fixture rendered');
check(await page.locator('.hm-orgchart-organization-link').count()===1,'1Depth native page link retained');
await page.locator('.hm-orgchart-organization-link').click();
check(await page.locator('.node[data-organization-id]').count()===expandedFixture.organizations.length && await page.locator('dialog[open]').count()===0,'Group link leaves hierarchy visible and no employee popup');
await popup(page,'Vanilla nested employee','expanded-nested-5');
check(vanilla.value.cards.every(c=>c.placeholder==='placeholder'),'Missing photos share person placeholder');
const augmented=structuredClone(expandedFixture);
for(let i=0;i<12;i++) augmented.employees.push({id:`many-${i}`,organizationId:'business-management',affiliationId:'hanmac',name:'김지우',position:'선임',jobTitle:null,profileImage:null,workStatus:'active'});
for(let i=0;i<8;i++) augmented.organizations.push({id:`new-sibling-${i}`,parentId:'business-planning',name:`추가 셀 ${i}`,type:'cell',leaderId:null,displayOrder:100+i});
await page.evaluate(async data=>{await window.handle.update(data);window.currentData=data;},augmented);
await ready(page,'auto');
const grown=await assertHierarchy(page,'Unequal staff + additional organizations',augmented);
check(grown.layout.tree.width>vanilla.layout.tree.width && grown.layout.tree.height>vanilla.layout.tree.height,'Organization/staff growth increases natural content width and height');
check(grown.value.viewport.scrollWidth>grown.value.viewport.clientWidth && grown.value.viewport.scrollHeight>grown.value.viewport.clientHeight,'Large tree has horizontal and vertical inner overflow');
const scrolled=await page.evaluate(()=>{const v=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-viewport');v.scrollLeft=v.scrollWidth;v.scrollTop=v.scrollHeight;return {x:v.scrollLeft,y:v.scrollTop};});
check(scrolled.x>0 && scrolled.y>0,'Both overflow axes scroll to far content');
await page.screenshot({path:path.join(output,'vanilla-overflow-scrolled.png'),fullPage:true});
const moved=structuredClone(augmented);
moved.employees=moved.employees.filter(e=>e.id!=='many-11');
moved.employees.find(e=>e.id==='many-0').organizationId='finance';
moved.organizations=moved.organizations.filter(o=>o.id!=='new-sibling-2');
await page.evaluate(async data=>window.handle.update(data),moved);
await ready(page,'auto');
await assertHierarchy(page,'Deletion and employee reassignment',moved);
check(await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('[data-employee-id="many-0"]').closest('.node').dataset.organizationId)==='finance','Membership change moves the employee card');
const imageData=structuredClone(expandedFixture);
imageData.employees.find(e=>e.id==='expanded-employee-01').profileImage='/packages/org-chart/src/assets/workdesk/bg_sky.png';
await page.evaluate(async data=>window.handle.update(data),imageData);
await ready(page,'auto');
check(await page.locator('[data-employee-id="expanded-employee-01"]').getAttribute('data-photo-state')==='image','Valid profile photo uses Employee data');
const hidden=structuredClone(expandedFixture);hidden.presentation.groups.showEmployees=false;
await page.evaluate(async data=>window.handle.update(data),hidden);
await ready(page,'auto');
await assertHierarchy(page,'Show employees false',hidden);
for(const [kind,code] of [['orphan','PARENT_REFERENCE'],['cycle','HIERARCHY_CYCLE']]) {
const bad=structuredClone(expandedFixture);
if(kind==='orphan')bad.organizations[1].parentId='missing';else bad.organizations[0].parentId='business-management';
const state=await page.evaluate(async data=>{await window.handle.update(data);return window.handle.getState();},bad);
check(state.status==='error' && state.issues.some(i=>i.code===code),`Invalid ${kind} reports existing validation policy`);
check(await page.locator('.node[data-organization-id]').count()===0,`Invalid ${kind} clears stale tree without silent omission`);
}
await page.evaluate(async()=>window.handle.reload());
await ready(page,'auto');
await assertHierarchy(page,'Reload restores source',expandedFixture);
await page.evaluate(()=>window.handle.destroy());
check(await page.locator('.hm-orgchart-host').count()===0,'Destroy removes Auto DOM and observers');
} finally {await page.close();}
});
await scenario('Actual React Storybook',async()=>{
const page=await newPage('storybook-auto',{width:1920,height:1080});
try {
const response=await page.request.get(`${storyOrigin}/index.json`);const index=await response.json();
const visible=Object.values(index.entries).filter(e=>e.type==='story'&&e.title==='OrgChart'&&!e.tags?.includes('internal')).map(e=>e.id).sort();
check(JSON.stringify(visible)===JSON.stringify(['orgchart--logo-personnel-summary','orgchart--department-colors','orgchart--full-composition','orgchart--expanded-org-chart'].sort()),'Storybook exactly four user menus',visible);
await page.goto(`${storyOrigin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);await ready(page,'auto');
const react=await assertHierarchy(page,'React 1920',expandedFixture);
const signature=value=>value.cards.map(c=>({id:c.id,kind:c.kind,width:c.box.width,height:c.box.height,name:c.name,role:c.role,font:c.font,asset:c.asset}));
check(JSON.stringify(signature(react.value))===JSON.stringify(signature(vanilla.value)),'HTML/React identical Core card content and geometry');
await page.screenshot({path:path.join(output,'storybook-expanded-1920.png'),fullPage:true});
for(const preset of presets) {
await page.getByRole('combobox',{name:'회사별 컬러 테마'}).click();await page.getByRole('option',{name:preset.name,exact:true}).click();
await page.waitForFunction(id=>document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.node')?.dataset.companyId===id,preset.id);
await ready(page,'auto');const value=await measure(page);
check(value.nodes.every(n=>n.companyId===preset.id && n.primary===preset.primary && n.backgroundColor===rgb(preset.primary) && JSON.stringify(n.secondary)===JSON.stringify(preset.secondary)),`${preset.name} Main/all secondary preset variables`);
const style=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot;return [...s.querySelectorAll('[data-card-presentation="expanded"]')].map(c=>getComputedStyle(c).borderTopColor);});
check(style.every(c=>c===rgb(preset.primary)),`${preset.name} employee accent matches preset`);
}
await page.getByRole('combobox',{name:'회사별 컬러 테마'}).click();await page.getByRole('option',{name:'한맥기술',exact:true}).click();await ready(page,'auto');
for(const width of [390,768,1280,1440]) {
await page.setViewportSize({width,height:width<1000?1024:900});await ready(page,'auto');
const checked=await assertHierarchy(page,`React ${width}`,expandedFixture);
const scrolled=await page.evaluate(()=>{const v=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-viewport');v.scrollLeft=v.scrollWidth;const x=v.scrollLeft;v.scrollLeft=0;return x;});
check(checked.layout.nodes[0].subtree.width <= checked.value.viewport.clientWidth || scrolled>0,`React ${width} wide tree accessible with horizontal scroll`);
await page.screenshot({path:path.join(output,`storybook-expanded-${width}.png`),fullPage:true});
}
await popup(page,'React nested card','expanded-team-leader-01');
await page.goto(`${storyOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story`);await ready(page,'fixed');
const fixed=await measure(page);
check(fixed.layoutStatus===null && fixed.controls===0 && fixed.cards.some(c=>c.asset),'Existing Full Composition Story keeps Fixed path and assets');
} finally {await page.close();}
});
}
} finally {
await browser.close();await new Promise(resolve=>server.close(resolve));
check(errors.length===0,'No new console/runtime errors',errors);
if(process.env.ORGCHART_VERIFY_RETRY === 'overflow') {
const previous=JSON.parse(await fs.readFile(path.join(output,'browser.json'),'utf8'));
const updates=new Map(results.map(result=>[result.name,result]));
const combined=previous.results.map(result=>updates.get(result.name)??result);
results.splice(0,results.length,...combined,...results.filter(result=>!previous.results.some(old=>old.name===result.name)));
measurements.unshift(...previous.measurements);
}
const summary={total:results.length,pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length};
await fs.writeFile(path.join(output,'browser.json'),JSON.stringify({scope:'P5-1B focused validation',summary,results,measurements,errors,warnings,notVerified:['Two reference SVGs and final design match','P5-2 collapse/zoom/pan','Historical full regression suite','Actual screen reader speech/touch/other browsers']},null,2));
console.log(JSON.stringify(summary));
console.log(JSON.stringify(results.filter(r=>r.status==='FAIL'),null,2));
if(summary.fail)process.exitCode=1;
}
+224
View File
@@ -0,0 +1,224 @@
import fs from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p5-23');
const playwright = process.env.ORGCHART_PLAYWRIGHT_MODULE;
const { chromium } = await import(playwright ? pathToFileURL(playwright).href : 'playwright');
const story = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6054';
const html = process.env.ORGCHART_HTML_ORIGIN ?? 'http://127.0.0.1:4189';
const fixtures = Object.fromEntries(await Promise.all(['small', 'medium', 'large'].map(async size => [size, JSON.parse(await fs.readFile(path.join(root, `examples/org-chart/data-expanded-${size}.json`), 'utf8'))])));
const fixed = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
const results = [], errors = [], timings = [], measurements = [];
const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); };
const mime = { '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf', '.json': 'application/json', '.png': 'image/png' };
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
if (pathname === '/shell') return response.writeHead(200, { 'Content-Type': 'text/html' }).end('<!doctype html><html><head><link rel="icon" href="data:,"><style>@font-face{font-family:"Noto Sans KR";src:url(/public/fonts/NotoSansKR-variable.ttf);font-weight:100 900}body{margin:0}#chart{width:100%}</style></head><body><main id="chart"></main></body></html>');
const file = path.resolve(root, '.' + pathname);
if (path.relative(root, file).startsWith('..')) return response.writeHead(403).end();
response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }).end(await fs.readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
await fs.mkdir(output, { recursive: true });
const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
const settle = () => page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve)))); });
async function ready(count) {
await page.waitForFunction(n => {
const s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return s?.querySelectorAll('.hm-orgchart-employee').length === n && s.querySelectorAll('link').length > 0 && [...s.querySelectorAll('link')].every(link => link.sheet);
}, count); await settle();
}
async function mount(data, options = {}, baseline = false) {
await page.goto(origin + '/shell');
const ms = await page.evaluate(async ({ origin, data, options, baseline }) => {
const start = performance.now();
const base = origin + (baseline ? '/test-results/org-chart-p5-23/baseline-dist' : '/packages/org-chart/dist');
const mod = await import(base + '/index.js');
window.handle = mod.createOrgChart({ container: document.getElementById('chart'), dataSource: new mod.JsonOrgChartDataSource(data), stylesheetUrls: { engine: base + '/engine.css', core: base + '/style.css' }, ...options });
await window.handle.mount();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return performance.now() - start;
}, { origin, data, options, baseline });
await settle(); return ms;
}
async function measure() {
return page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
const rect = el => { const r = el.getBoundingClientRect(); return { x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,cx:r.x+r.width/2 }; };
const nodes = [...s.querySelectorAll('.node[data-organization-id]')].map(n => ({ id:n.dataset.organizationId, parentId:n.dataset.parentId, company:n.dataset.companyId, depth:+n.dataset.depth, rect:rect(n), article:rect(n.querySelector('article')), subtree:rect(n.parentElement), domParent:n.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '', color:getComputedStyle(n.querySelector('h3')).backgroundColor, ink:getComputedStyle(n.querySelector('h3')).color }));
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => {
const css = getComputedStyle(c), svg = c.closest('article')?.querySelector('svg.hm-orgchart-employee-connectors');
const line = svg && [...svg.querySelectorAll('path')].find(p => p.dataset.employeeId === c.dataset.employeeId);
const direction = c.dataset.connectionDirection, box = rect(c), scale = +s.querySelector('.hm-orgchart-viewport').dataset.scale || 1;
const end = line ? { x:svg.getBoundingClientRect().x + +line.dataset.endX * scale, y:svg.getBoundingClientRect().y + +line.dataset.endY * scale } : null;
const target = direction==='left' ? {x:box.x,y:box.y+box.height/2} : direction==='right' ? {x:box.right,y:box.y+box.height/2} : {x:box.cx,y:box.y};
return { id:c.dataset.employeeId, kind:c.dataset.cardKind, direction, company:c.dataset.companyId, box, border:[css.borderTopColor,css.borderRightColor,css.borderBottomColor,css.borderLeftColor], shadow:css.boxShadow, name:c.querySelector('.hm-orgchart-person-name')?.textContent, role:c.querySelector('.hm-orgchart-person-role')?.textContent, asset:c.dataset.cardAsset, endpointError:end?Math.hypot(end.x-target.x,end.y-target.y):null };
});
const collisions = [];
for (let i=0;i<cards.length;i++) for (const b of cards.slice(i+1)) {
const a=cards[i]; if (Math.min(a.box.right,b.box.right)-Math.max(a.box.x,b.box.x)>0.5 && Math.min(a.box.bottom,b.box.bottom)-Math.max(a.box.y,b.box.y)>0.5) collisions.push([a.id,b.id]);
}
const viewport = s.querySelector('.hm-orgchart-viewport'), forest=s.querySelector('.hm-orgchart-forest');
return { nodes, cards, collisions, scale:+viewport.dataset.scale, forest:rect(forest), viewport:rect(viewport), viewportState:{x:+viewport.dataset.panX,y:+viewport.dataset.panY}, doc:{width:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth,height:document.documentElement.clientHeight,scrollHeight:document.documentElement.scrollHeight}, controls:s.querySelectorAll('.hm-orgchart-expanded-toolbar button').length };
});
}
async function hierarchy(name, data) {
const m = await measure(); measurements.push({ name, value:m });
check(m.nodes.length===data.organizations.length, name+' all organizations');
check(m.cards.length===data.employees.length && new Set(m.cards.map(c=>c.id)).size===data.employees.length, name+' unique employees exactly once');
check(m.collisions.length===0,name+' no card overlap',m.collisions);
check(m.nodes.every(n=>n.parentId===n.domParent),name+' DOM ancestry matches source');
check(m.nodes.every(n=>!n.parentId || n.rect.y > m.nodes.find(p=>p.id===n.parentId).article.bottom),name+' children below own parent');
check(m.nodes.every(n=>m.nodes.filter(p=>p.depth===n.depth).every(p=>Math.abs(p.rect.y-n.rect.y)<1)),name+' same depth horizontal alignment');
check(m.cards.every(c=>c.endpointError!==null && c.endpointError<1),name+' actual connector endpoints match cards',m.cards.filter(c=>c.endpointError===null||c.endpointError>=1));
check(m.cards.every(c=>c.border.filter((color,index)=>index!==({top:0,right:1,left:3}[c.direction])).every(color=>color===c.border[2]) && c.border[{top:0,right:1,left:3}[c.direction]]!==c.border[2]),name+' only incoming border side accented');
check(m.doc.scrollWidth<=m.doc.width+1,name+' no document horizontal overflow');
return m;
}
async function popup(id) {
const card = page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`).first();
await card.focus(); await card.press('Enter'); await page.locator('dialog[open]').waitFor();
const detail = await page.evaluate(()=> {const s=document.querySelector('.hm-orgchart-host').shadowRoot,d=s.querySelector('dialog');return {focused:d.contains(s.activeElement),transformed:!!d.closest('.hm-orgchart-forest'),text:d.textContent};});
check(detail.focused && !detail.transformed,'Popup focus inside; outside transformed forest',detail);
await page.keyboard.press('Escape');
check(await page.evaluate(id=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId===id,id),'Popup Escape restores card focus');
}
async function timedToggle(id, key='Enter') {
const button=page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`);
await button.focus();
await page.evaluate(()=>{window.toggleStart=performance.now();});
await button.press(key); await settle();
const ms=await page.evaluate(()=>performance.now()-window.toggleStart);
timings.push({action:'toggle '+id,ms}); return ms;
}
try {
if (!process.env.ORGCHART_VERIFY_ONLY) {
await mount(fixed,{displayMode:'readOnly'},true); const before=await measure();
await mount(fixed,{displayMode:'readOnly'}); const after=await measure();
const fixedSig=m=>m.cards.map(c=>({id:c.id,kind:c.kind,asset:c.asset,name:c.name,role:c.role,box:c.box,border:c.border}));
check(JSON.stringify(fixedSig(before))===JSON.stringify(fixedSig(after)),'Fixed baseline/current exact card geometry, SVG, typography, color');
check(after.controls===0,'Fixed no Auto controls'); await popup(after.cards[0].id);
await page.screenshot({path:path.join(output,'fixed-1440.png'),fullPage:true});
for(const [size,data] of Object.entries(fixtures)) {
const ms=await mount(data,{layoutMode:'auto',displayMode:'readOnly',employeeConnections:Object.fromEntries(data.organizations.map((org,index)=>[org.id,['top','left','right'][index%3]]))});
timings.push({action:`${size} initial mount (module/styles + 2 animation frames)`,ms});
await ready(data.employees.length); await hierarchy('HTML '+size,data);
await page.screenshot({path:path.join(output,`html-${size}-fit.png`),fullPage:true});
}
const data=fixtures.large;
await timedToggle('hanmac-office-0');
check(await page.locator('[data-organization-id="hanmac-office-0"].hm-orgchart-expanded-toggle').getAttribute('aria-expanded')==='false','Enter collapses group');
await timedToggle('hanmac-division-0','Space'); await timedToggle('hanmac-division-0','Space');
check(await page.locator('[data-organization-id="hanmac-office-0"].hm-orgchart-expanded-toggle').getAttribute('aria-expanded')==='false','Parent re-open preserves child collapse');
check(await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.organizationId==='hanmac-division-0'),'Toggle preserves initiating focus');
await timedToggle('hanmac-office-0'); await ready(300);
const selectMs=await page.evaluate(async()=>{const t=performance.now();await window.handle.setSelectedCompanies(['hanmac']);await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));return performance.now()-t;});timings.push({action:'300 single company selection + layout',ms:selectMs});
let m=await measure();check(new Set(m.nodes.map(n=>n.company)).size===1 && m.nodes.every(n=>n.company==='hanmac'),'Single company independent root');
check(await page.evaluate(()=>window.handle.getState().summary.raw.total)===300,'Company filter preserves immutable source total');
await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac','ptc']));await settle();m=await measure();check(new Set(m.nodes.map(n=>n.company)).size===2,'Multiple companies without synthetic root');
await page.evaluate(async()=>window.handle.setSelectedCompanies([]));await settle();check(await page.locator('.hm-orgchart-expanded-empty').count()===1 && await page.locator('.node[data-organization-id]').count()===0,'Explicit empty selection');
await page.evaluate(async()=>window.handle.setSelectedCompanies());await ready(300);
await page.getByRole('button',{name:'100% 복원',exact:true}).click();let zoom=await measure();check(zoom.scale===1,'100% resets scale');
await page.getByRole('button',{name:'확대',exact:true}).click();check((await measure()).scale>1,'Zoom in button');
await page.getByRole('button',{name:'축소',exact:true}).click();check(Math.abs((await measure()).scale-1)<0.0001,'Zoom out button');
const area=page.locator('.hm-orgchart-viewport'),box=await area.boundingBox();
await page.mouse.move(box.x+box.width-50,box.y+50);const state=(await measure()).viewportState;
await page.mouse.down();await page.mouse.move(box.x+box.width-150,box.y+120,{steps:8});await page.mouse.up();
const moved=(await measure()).viewportState;check(moved.x!==state.x&&moved.y!==state.y,'Mouse drag pans forest');check(await page.locator('dialog[open]').count()===0,'Drag does not open popup');
await page.evaluate(()=> {window.handle.zoomTo(999);});check((await measure()).scale===3,'Zoom clamps maximum');
await page.evaluate(()=> {window.handle.zoomTo(0);});check((await measure()).scale===0.05,'Zoom clamps minimum');
await page.getByRole('button',{name:'Fit to View',exact:true}).click();zoom=await measure();check(zoom.forest.width<=zoom.viewport.width+1&&zoom.forest.height<=zoom.viewport.height+1,'Fit contains current forest');
await popup('virtual-001');
const beforeWheel=(await measure()).scale;
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,-150);await settle();check((await measure()).scale===beforeWheel,'Ordinary wheel does not zoom or consume scroll');
await page.keyboard.down('Control');await page.mouse.wheel(0,-150);await page.keyboard.up('Control');await settle();check((await measure()).scale>beforeWheel,'Ctrl wheel zooms');
await mount(data,{layoutMode:'auto',companyTopNodes:{hanmac:{title:'최고기술책임자',name:'가상리더',organizationName:'가상 기술회사'},saman:{display:'titleOnly'},halla:{display:'hidden'},baron:{employeeId:null}}});
check(await page.locator('[data-employee-id="virtual-001"] .hm-orgchart-person-role').textContent()==='최고기술책임자','Custom top title uses data/presentation, not representative constant');
check(await page.locator('.hm-orgchart-top-node-title').count()===1,'Top titleOnly mode');
check(await page.locator('.node[data-organization-id="halla-root"] .hm-orgchart-employee').count()===0,'Hidden top card mode');
check(await page.locator('.node[data-organization-id="baron-root"]').count()===1,'No top employee retains original organization root');
}
if (process.env.ORGCHART_VERIFY_ONLY !== 'extras') {
const data=fixtures.large;
await page.goto(html+'/expanded.html');await ready(20);await page.selectOption('#dataset','large');await ready(300);const htmlValue=await hierarchy('Real HTML large',data);
await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(20);
await page.getByRole('combobox',{name:'가상 데이터'}).click();await page.getByRole('option',{name:'Large · 300명',exact:true}).click();await ready(300);
const react=await hierarchy('React large',data);
const signature=m=>m.cards.map(c=>({id:c.id,kind:c.kind,role:c.role,name:c.name,company:c.company,direction:c.direction,border:c.border,width:Math.round(c.box.width/m.scale),height:Math.round(c.box.height/m.scale)}));
check(JSON.stringify(signature(htmlValue))===JSON.stringify(signature(react)),'Actual HTML/React shared card geometry/content/color/direction');
const index=await (await page.request.get(story+'/index.json')).json();check(Object.values(index.entries).filter(e=>e.type==='story'&&e.title==='OrgChart'&&!e.tags?.includes('internal')).length===4,'Four existing user Storybook menus retained');
await page.getByRole('button',{name:'선택 해제',exact:true}).click();await page.locator('.hm-orgchart-expanded-empty').waitFor();await page.getByRole('checkbox',{name:'한맥기술',exact:true}).click();await settle();check((await measure()).nodes.every(n=>n.company==='hanmac'),'React single selection');
await page.getByRole('checkbox',{name:'피티씨',exact:true}).click();await settle();check(new Set((await measure()).nodes.map(n=>n.company)).size===2,'React multiple selection');
await page.getByRole('button',{name:'전체 회사',exact:true}).click();await ready(300);
await page.screenshot({path:path.join(output,'storybook-large-1440.png'),fullPage:true});
for(const width of [390,768,1280,1440]) {
await page.setViewportSize({width,height:1000});await settle();const m=await measure();measurements.push({name:'React viewport '+width,value:m});
check(m.doc.scrollWidth<=m.doc.width+1,'React '+width+' no page overflow',m.doc);check(m.controls===4,'React '+width+' zoom toolbar accessible');
await page.screenshot({path:path.join(output,`storybook-${width}.png`),fullPage:true});
}
}
if (!process.env.ORGCHART_VERIFY_ONLY || process.env.ORGCHART_VERIFY_ONLY === 'extras') {
const data=fixtures.large;
const directions=Object.fromEntries(data.organizations.map((org,i)=>[org.id,['top','left','right'][i%3]]));
await mount(data,{layoutMode:'auto',employeeConnections:directions,organizationLinks:{'hanmac-root':'#company-detail'}});
const expected={hanmac:'#CB2E28',saman:'#F08300','jangheon-industry':'#4580A6',jangheon:'#1D418D',ptc:'#84338E',halla:'#0068B7',baron:'#0C367F'};
const rgb=hex=>`rgb(${[1,3,5].map(i=>parseInt(hex.slice(i,i+2),16)).join(', ')})`;
const colors=await measure();
check(colors.nodes.every(n=>n.color===rgb(expected[n.company])),'All seven group header preset colors exact');
check(colors.cards.every(c=>c.border[{top:0,right:1,left:3}[c.direction]]===rgb(expected[c.company])),'All seven employee border preset colors exact');
check(new Set(colors.cards.map(c=>c.direction)).size===3,'Real top/left/right connector examples present');
const duration=await page.evaluate(async()=>{const t=performance.now();document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').click();await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));return performance.now()-t;});
timings.push({action:'300 group collapse (native click + 2 frames)',ms:duration});
await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').click());await ready(300);
const zoomPan=await page.evaluate(async()=>{const t=performance.now();window.handle.zoomTo(1.2);for(let i=0;i<60;i++)window.handle.panBy(1,-1);await new Promise(r=>requestAnimationFrame(r));return performance.now()-t;});
timings.push({action:'300 zoom + 60 pan updates + frame',ms:zoomPan});
await page.evaluate(()=>window.handle.fitToView());await settle();
const drag=await page.locator('.hm-orgchart-employee[data-employee-id="virtual-001"]').boundingBox();
await page.mouse.move(drag.x+drag.width/2,drag.y+drag.height/2);await page.mouse.down();await page.mouse.move(drag.x+drag.width/2+30,drag.y+drag.height/2+30,{steps:5});await page.mouse.up();
check(await page.locator('dialog[open]').count()===0,'Dragging an employee card suppresses popup click');
const touch=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,v=s.querySelector('.hm-orgchart-viewport'),before=window.handle.getViewportState();v.setPointerCapture=()=>{};v.hasPointerCapture=()=>false;for(const [type,x,y] of [['pointerdown',50,50],['pointermove',100,100],['pointerup',100,100]])v.dispatchEvent(new PointerEvent(type,{pointerId:42,pointerType:'touch',isPrimary:true,button:0,clientX:x,clientY:y,bubbles:true}));const after=window.handle.getViewportState();return {before,after};});
check(touch.after.x-touch.before.x===50&&touch.after.y-touch.before.y===50,'Touch pointer event route pans (synthetic; real device unverified)',touch);
await page.locator('.hm-orgchart-expanded-link').click();check(await page.locator('.node[data-organization-id]').count()===data.organizations.length,'Separate group page link does not collapse');
await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac']));await settle();
await timedToggle('hanmac-office-0');await page.evaluate(async()=>window.handle.setSelectedCompanies(['saman']));await settle();
check(await page.locator('.hm-orgchart-expanded-toggle[aria-expanded="false"]').count()===0,'Other company remains independently expanded');
await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac']));await settle();
check(await page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').getAttribute('aria-expanded')==='false','Company re-selection preserves its collapse state');
const updated=structuredClone(data);updated.organizations.push({id:'hanmac-empty-leaf',companyId:'hanmac',parentId:'hanmac-root',name:'가상 빈 조직',type:'team',leaderId:null,displayOrder:5});
await page.evaluate(async d=>window.handle.update(d),updated);await settle();
check(await page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-empty-leaf"]').isDisabled(),'Empty group title is disabled');
await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(20);
await page.getByRole('button',{name:'선택 해제',exact:true}).click();await page.getByRole('checkbox',{name:'한맥기술',exact:true}).click();await settle();
await page.getByRole('button',{name:'100% 복원',exact:true}).click();
await page.screenshot({path:path.join(output,'storybook-small-hanmac-100.png'),fullPage:true});
await page.locator('.hm-orgchart-expanded-toggle').first().focus();await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter');await settle();
check(await page.locator('.hm-orgchart-expanded-toggle').first().getAttribute('aria-expanded')==='false','React group keyboard collapse uses same Core');
await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter');await settle();
await page.getByRole('checkbox',{name:'삼안',exact:true}).click();await settle();
await page.getByRole('combobox',{name:'가상 데이터'}).click();await page.getByRole('option',{name:'Medium · 100명',exact:true}).click();await ready(100);
check((await measure()).cards.length===100,'React fixture switch restores all 100 employees after selections');
}
} catch(error) {check(false,'Suite completed',{message:error.message,stack:error.stack});}
finally {
check(errors.length===0,'No new browser console/runtime errors',errors);
await browser.close();await new Promise(resolve=>server.close(resolve));
const summary={total:results.length,pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length};
const file=path.join(output,'browser.json');
if(process.env.ORGCHART_VERIFY_ONLY) {
const previous=JSON.parse(await fs.readFile(file,'utf8'));
const names=new Set(results.map(result=>result.name));
results.unshift(...previous.results.filter(result=>result.name!=='Suite completed' && !names.has(result.name)));
timings.unshift(...previous.timings);measurements.unshift(...previous.measurements);
summary.total=results.length;summary.pass=results.filter(r=>r.status==='PASS').length;summary.fail=results.filter(r=>r.status==='FAIL').length;
}
await fs.writeFile(file,JSON.stringify({scope:'P5-2/P5-3 affected paths',summary,results,timings,measurements,errors,notVerified:['Reference SVG design match','Real touch device, screen reader and other browsers','Historical full regression suite']},null,2));
console.log(JSON.stringify({summary,timings}));if(summary.fail)process.exitCode=1;
}
+221
View File
@@ -0,0 +1,221 @@
// Focused P5-4 checks. Does not replay P5-1B/P5-23 suites or performance timings.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p5-4');
const playwright = process.env.ORGCHART_PLAYWRIGHT_MODULE;
const { chromium } = await import(playwright ? pathToFileURL(playwright).href : 'playwright');
const fixtures = Object.fromEntries(await Promise.all(['small', 'medium', 'large'].map(async size => [size, JSON.parse(await fs.readFile(path.join(root, `examples/org-chart/data-expanded-${size}.json`), 'utf8'))])));
const fixed = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
const results = [], errors = [], measurements = [];
const consoleOnly = process.env.ORGCHART_P54_RESUME === 'console';
const resume = consoleOnly || process.env.ORGCHART_P54_RESUME === 'remaining';
let baseline;
if (resume) {
const prior = JSON.parse(await fs.readFile(path.join(output, consoleOnly ? 'browser-before-console.json' : 'browser-initial.json'), 'utf8'));
results.push(...prior.results.filter(r => r.status === 'PASS'));
measurements.push(...prior.measurements);
baseline = prior.measurements.find(m => m.name === 'small geometry').value;
}
const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); writeFileSync(path.join(output,'progress.json'),JSON.stringify({results},null,2)); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); };
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf', '.json': 'application/json', '.svg': 'image/svg+xml' };
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
if (pathname === '/favicon.ico') return response.writeHead(204).end();
if (pathname === '/shell') return response.writeHead(200, { 'Content-Type': 'text/html' }).end('<!doctype html><html lang="ko"><head><link rel="icon" href="data:,"><style>@font-face{font-family:"Noto Sans KR";src:url(/public/fonts/NotoSansKR-variable.ttf);font-weight:100 900}body{margin:0}#chart{width:100%}</style></head><body><main id="chart"></main></body></html>');
if (pathname === '/virtual-profile.svg') return response.writeHead(200, { 'Content-Type': 'image/svg+xml' }).end('<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36"><rect width="36" height="36" fill="gray"/></svg>');
const file = path.resolve(root, pathname.startsWith('/assets/') ? 'test-results/org-chart-p5-4/react-build/dist'+pathname : '.' + pathname), relative = path.relative(root, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
const data = await fs.readFile(file);
response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }).end(data);
} catch { response.writeHead(404).end(); }
});
await fs.mkdir(output, { recursive: true });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const track = p => { p.on('pageerror', e => errors.push(e.message)); p.on('console', m => { if (m.type() === 'error') errors.push({message:m.text(),location:m.location()}); }); };
track(page);
const settle = p => p.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve)))); });
const shadow = '.hm-orgchart-host';
async function ready(p, count) {
await p.waitForFunction(count => {
const s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return s?.querySelectorAll('.hm-orgchart-employee').length === count && [...s.querySelectorAll('link')].every(link => link.sheet);
}, count); await settle(p);
}
async function mount(data, options = {}, baseline = false) {
await page.goto(origin + '/shell');
await page.evaluate(async ({ origin, data, options, baseline }) => {
const base = origin + (baseline ? '/test-results/org-chart-p5-4/baseline-dist' : '/packages/org-chart/dist');
const mod = await import(base + '/index.js');
window.handle = mod.createOrgChart({ container: document.getElementById('chart'), dataSource: new mod.JsonOrgChartDataSource(data), stylesheetUrls: { engine: base + '/engine.css', core: base + '/style.css' }, ...options });
await window.handle.mount();
}, { origin, data, options, baseline }); await settle(page);
}
async function measure(p = page) {
return p.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
const box = e => { const r = e.getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height }; };
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => {
const cs = getComputedStyle(c), avatar = c.querySelector('.hm-orgchart-avatar'), name = c.querySelector('.hm-orgchart-person-name'), role = c.querySelector('.hm-orgchart-person-role');
const article = c.closest('.hm-orgchart-organization'), endpoint = article?.querySelector(`path[data-employee-id="${c.dataset.employeeId}"]`);
let x = 0, y = 0, cur = c;
while (article && cur && cur !== article) { x += cur.offsetLeft; y += cur.offsetTop; cur = cur.offsetParent; }
const direction = c.dataset.connectionDirection;
const target = direction === 'left' ? [x, y+c.offsetHeight/2] : direction === 'right' ? [x+c.offsetWidth, y+c.offsetHeight/2] : [x+c.offsetWidth/2,y];
return { id:c.dataset.employeeId, kind:c.dataset.cardKind, company:c.dataset.companyId, asset:c.dataset.cardAsset, name:name.textContent, role:role.textContent,
size:[c.offsetWidth,c.offsetHeight], box:box(c), avatar:[avatar.offsetWidth,avatar.offsetHeight], radius:getComputedStyle(avatar).borderRadius,
font:getComputedStyle(name).fontFamily, fontSize:[getComputedStyle(role).fontSize,getComputedStyle(name).fontSize], border:[cs.borderTopColor,cs.borderRightColor,cs.borderBottomColor,cs.borderLeftColor], direction,
endpointError:endpoint ? Math.hypot(Number(endpoint.dataset.endX)-target[0],Number(endpoint.dataset.endY)-target[1]) : null,
label:c.getAttribute('aria-label'), photo:c.dataset.photoState, image:c.querySelector('.hm-orgchart-avatar img').getAttribute('src'), background:cs.backgroundColor };
});
const v=s.querySelector('.hm-orgchart-viewport'), f=s.querySelector('.hm-orgchart-forest');
return { cards, nodes:[...s.querySelectorAll('.node[data-depth]')].map(n=>({id:n.dataset.organizationId,parent:n.dataset.parentId,domParent:n.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '',depth:Number(n.dataset.depth),box:box(n),article:box(n.querySelector('.hm-orgchart-organization')),company:n.dataset.companyId})),
titles:[...s.querySelectorAll('.hm-orgchart-organization-name')].map(t=>({text:t.textContent,bg:getComputedStyle(t).backgroundColor,color:getComputedStyle(t).color})),
viewport:box(v), forest:box(f), scroll:[v.scrollLeft,v.scrollTop,v.scrollWidth,v.clientWidth,v.scrollHeight,v.clientHeight],
document:[document.documentElement.scrollWidth,innerWidth,document.documentElement.scrollHeight,document.documentElement.clientHeight], design:s.querySelector('[data-card-design-status]')?.dataset.cardDesignStatus };
});
}
function overlap(cards) {
return cards.some((a,i)=>cards.slice(i+1).some(b=>Math.min(a.box.x+a.box.width,b.box.x+b.box.width)-Math.max(a.box.x,b.box.x)>0.5 && Math.min(a.box.y+a.box.height,b.box.y+b.box.height)-Math.max(a.box.y,b.box.y)>0.5));
}
async function popup(p, id, key = 'Enter') {
const card=p.locator(`${shadow} .hm-orgchart-employee[data-employee-id="${id}"]`);
await card.focus(); await settle(p); await card.press(key); await p.locator('dialog[open]').waitFor();
const props=await p.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,d=s.querySelector('dialog');return {title:s.getElementById(d.getAttribute('aria-labelledby'))?.textContent,close:d.querySelector('button').getAttribute('aria-label'),outside:!d.closest('.hm-orgchart-forest'),focus:d.contains(s.activeElement),scale:getComputedStyle(d).transform};});
check(props.title==='직원 정보' && props.close==='직원 정보 닫기' && props.outside && props.focus && props.scale==='none','Popup title, close name, top layer and focus',props);
await p.keyboard.press('Escape'); await settle(p);
check(await p.evaluate(id=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId===id,id),'Popup Escape restores original focus');
}
try {
if (consoleOnly) {
await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6054')+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(page,20);
await page.goto(origin+'/test-results/org-chart-p5-4/react-build/dist/index.html'); await ready(page,20);
await page.goto((process.env.ORGCHART_HTML_ORIGIN ?? 'http://127.0.0.1:4189')+'/expanded.html'); await ready(page,20);
check(errors.length===0,'No new console/runtime errors',errors);
} else {
let m;
if (!resume) {
await mount(fixtures.small,{layoutMode:'auto',displayMode:'readOnly'},true); baseline=await measure();
await mount(fixtures.small,{layoutMode:'auto',displayMode:'readOnly'}); await ready(page,20); m=await measure();
check(JSON.stringify(baseline.cards.map(c=>[c.id,c.kind,c.size,c.avatar,c.fontSize,c.border,c.background]))===JSON.stringify(m.cards.map(c=>[c.id,c.kind,c.size,c.avatar,c.fontSize,c.border,c.background])),'Auto baseline/current card size, photo, typography, color unchanged');
check(m.design==='current-presentation','Current presentation status matches revised requirements');
measurements.push({name:'small geometry',value:m});
const toggle=page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]');
await toggle.focus(); await settle(page);
const normal=await toggle.evaluate(e=>getComputedStyle(e).backgroundColor); await toggle.hover();
const hover=await toggle.evaluate(e=>getComputedStyle(e).backgroundColor);
check(normal!==hover,'Group title Hover feedback',{normal,hover});
await page.mouse.move(0,0); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); await settle(page);
check(await toggle.evaluate(e=>e.matches(':focus-visible') && getComputedStyle(e).outlineStyle==='solid'),'Group title keyboard Focus outline');
await toggle.press('Enter'); await settle(page);
check(await toggle.getAttribute('aria-expanded')==='false','Enter collapse aria-expanded');
await page.screenshot({path:path.join(output,'group-collapsed.png'),fullPage:true});
await toggle.press('Space'); await ready(page,20);
check(await toggle.getAttribute('aria-expanded')==='true','Space expand aria-expanded');
check(await toggle.evaluate(e=>e.getRootNode().activeElement===e),'Toggle focus returns after rerender');
await page.screenshot({path:path.join(output,'group-expanded.png'),fullPage:true});
await page.evaluate(()=>{window.handle.zoomTo(1);window.handle.panBy(-4000,-2000);});
const card=page.locator('.hm-orgchart-employee').first(); await card.focus(); await page.keyboard.press('Tab'); await settle(page);
const visible=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,v=s.querySelector('.hm-orgchart-viewport'),a=s.activeElement,b=a.getBoundingClientRect(),r=v.getBoundingClientRect();return {within:b.left>=r.left && b.top>=r.top && b.right<=r.right && b.bottom<=r.bottom,scale:Number(v.dataset.scale),scroll:[v.scrollLeft,v.scrollTop],active:a.className};});
check(visible.within && visible.scale===1 && visible.scroll.every(n=>n===0),'Tab reveals offscreen control without Zoom or hidden scrolling',visible);
await page.evaluate(()=>window.handle.fitToView()); await settle(page);
const v=page.locator('.hm-orgchart-viewport'); const vb=await v.boundingBox();
const panBefore=await page.evaluate(()=>window.handle.getViewportState());
await page.mouse.move(vb.x+20,vb.y+20); await page.mouse.down(); await page.mouse.move(vb.x+70,vb.y+50); await page.mouse.up(); await settle(page);
const panAfter=await page.evaluate(()=>window.handle.getViewportState());
check(panAfter.x!==panBefore.x && panAfter.y!==panBefore.y,'Trusted mouse Pan remains operational');
await popup(page,m.cards[0].id,'Space');
await page.evaluate(()=>window.handle.fitToView()); await settle(page);
for (const size of ['medium','large']) {
const data=fixtures[size],connections=Object.fromEntries(data.organizations.map((o,i)=>[o.id,['top','left','right'][i%3]]));
await mount(data,{layoutMode:'auto',displayMode:'readOnly',employeeConnections:connections}); await ready(page,data.employees.length); m=await measure();
check(m.cards.length===data.employees.length && new Set(m.cards.map(c=>c.id)).size===data.employees.length,size+' unique complete employee rendering');
check(!overlap(m.cards) && m.nodes.every(n=>n.parent===n.domParent && (!n.parent || m.nodes.some(p=>p.id===n.parent && p.article.y+p.article.height<n.box.y))),size+' no card overlap, child below own parent');
check(m.cards.every(c=>c.endpointError!==null && c.endpointError<1),size+' actual endpoints aligned');
check(m.cards.every(c=>{const i={top:0,right:1,left:3}[c.direction];return c.border[i]!==c.border[2] && c.border.every((b,j)=>j===i || b===c.border[2]);}),size+' top/left/right single-side accent');
check(m.forest.x>=m.viewport.x-1 && m.forest.y>=m.viewport.y-1 && m.forest.x+m.forest.width<=m.viewport.x+m.viewport.width+1 && m.forest.y+m.forest.height<=m.viewport.y+m.viewport.height+1,size+' Fit includes all cards');
measurements.push({name:size,value:m}); await page.screenshot({path:path.join(output,`html-${size}-fit.png`),fullPage:true});
}
await page.evaluate(()=>window.handle.setSelectedCompanies(['hanmac','saman'])); await settle(page); m=await measure();
check(new Set(m.nodes.map(n=>n.company)).size===2 && m.nodes.filter(n=>!n.parent).length===2,'Two selected companies retain independent roots');
await page.screenshot({path:path.join(output,'two-companies.png'),fullPage:true});
await page.evaluate(()=>window.handle.setSelectedCompanies(['hanmac'])); await settle(page);
check((await measure()).nodes.every(n=>n.company==='hanmac'),'Single company selection');
await page.getByRole('button',{name:'100% 복원',exact:true}).click(); await settle(page);
await page.screenshot({path:path.join(output,'cards-100-percent.png'),fullPage:true});
await mount(fixed,{displayMode:'readOnly'},true); const fixedBefore=await measure();
await mount(fixed,{displayMode:'readOnly'}); const fixedAfter=await measure();
const fixedSig=m=>m.cards.map(c=>[c.id,c.asset,c.box,c.name,c.role,c.border,c.avatar]);
check(JSON.stringify(fixedSig(fixedBefore))===JSON.stringify(fixedSig(fixedAfter)),'Fixed baseline exact geometry/SVG/text/color preservation');
check(await page.locator('.hm-orgchart-expanded-toggle').count()===0,'Fixed has no Auto toggles');
await popup(page,fixedAfter.cards[0].id); await page.screenshot({path:path.join(output,'fixed-1440.png'),fullPage:true});
} else {
// Only the two failed layout assertions are retried, with subtree ancestry
// instead of an invalid bound requiring every sibling to overlap its parent.
for (const size of ['medium','large']) {
await mount(fixtures[size],{layoutMode:'auto',displayMode:'readOnly'}); await ready(page,fixtures[size].employees.length); m=await measure();
check(!overlap(m.cards) && m.nodes.every(n=>n.parent===n.domParent && (!n.parent || m.nodes.some(p=>p.id===n.parent && p.article.y+p.article.height<n.box.y))),size+' no card overlap, child below own parent');
}
}
// A virtual profile asset, not a real person; verify override and fallback.
const image=origin+'/virtual-profile.svg';
const data=structuredClone(fixtures.small); data.employees[0].position='팀 장';
await mount(data,{layoutMode:'auto',displayMode:'readOnly',companyTopNodes:{hanmac:{profileImage:image,title:'사업 총괄'}}}); await ready(page,20); m=await measure();
check(m.cards[0].photo==='image' && m.cards[0].image===image && m.cards[0].radius==='0px','Top-node virtual profile override uses noncircular slot');
check(m.cards[0].role==='사업총괄' && data.employees[0].position==='팀 장','Display position removes whitespace; source remains unchanged');
await page.locator('.hm-orgchart-avatar img').first().evaluate(e=>e.dispatchEvent(new Event('error'))); await settle(page);
check((await measure()).cards[0].photo==='placeholder','Image failure returns common placeholder (error-event simulation)');
// Real React Story runtime: same host controls and shared Core.
const story=process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6054';
await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(page,20); m=await measure();
const expected=baseline.cards.map(c=>[c.id,c.size,c.avatar,c.name,c.role,c.fontSize,c.border[2]]);
check(JSON.stringify(m.cards.map(c=>[c.id,c.size,c.avatar,c.name,c.role,c.fontSize,c.border[2]]))===JSON.stringify(expected),'HTML/React same shared-card geometry, photo and typography');
check(m.cards.every(c=>c.font.includes('Noto Sans KR') && c.label.includes('직원 정보 보기')),'React computed Noto family and meaningful card names');
check(await page.getByRole('checkbox',{name:'한맥기술'}).count()===1 && await page.getByRole('combobox',{name:'가상 데이터'}).count()===1,'React company and data controls have connected names');
const index=await (await fetch(story+'/index.json')).json();
check(Object.values(index.entries).filter(e=>e.title==='OrgChart' && e.type==='story' && !e.tags?.includes('internal') && !e.tags?.includes('verification')).length===4,'Four public OrgChart Stories preserved');
for(const width of [390,768,1280,1440]) {
await page.setViewportSize({width,height:1000}); await settle(page); const mm=await measure();
check(mm.document[0]<=width+1 && mm.cards.length===20 && mm.scroll[0]===0 && mm.scroll[1]===0,`React ${width}px no horizontal document overflow or hidden viewport scroll`,{document:mm.document,scroll:mm.scroll});
await page.screenshot({path:path.join(output,`storybook-${width}.png`),fullPage:true});
}
await page.getByRole('combobox').click(); await page.getByRole('option',{name:'Large · 300명',exact:true}).click(); await ready(page,300);
await page.screenshot({path:path.join(output,'storybook-large.png'),fullPage:true});
const largeReact=await measure(); check(largeReact.cards.length===300 && !overlap(largeReact.cards),'React Large selection renders 300 cards without overlap');
await popup(page,largeReact.cards[0].id);
await page.goto(origin+'/test-results/org-chart-p5-4/react-build/dist/index.html'); await ready(page,20);
check((await measure()).design==='current-presentation','Built React bootstrap mounts revised shared Core');
const touchContext=await browser.newContext({viewport:{width:390,height:844},isMobile:true,hasTouch:true});
const touchPage=await touchContext.newPage(); track(touchPage);
await touchPage.goto((process.env.ORGCHART_HTML_ORIGIN ?? 'http://127.0.0.1:4189')+'/expanded.html'); await ready(touchPage,20);
const touchToggle=touchPage.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]');
await touchToggle.tap(); await settle(touchPage); check(await touchToggle.getAttribute('aria-expanded')==='false','Mobile emulation touch tap collapses title');
await touchToggle.tap(); await ready(touchPage,20);
const ts=await touchPage.locator('.hm-orgchart-viewport').boundingBox(),cdp=await touchContext.newCDPSession(touchPage);
const touchBefore=await touchPage.evaluate(()=>window.expandedHandle.getViewportState());
await cdp.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:ts.x+20,y:ts.y+20}]});
await cdp.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x:ts.x+65,y:ts.y+55}]});
await cdp.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]}); await settle(touchPage);
const touchAfter=await touchPage.evaluate(()=>window.expandedHandle.getViewportState());
check(touchAfter.x!==touchBefore.x && touchAfter.y!==touchBefore.y,'Mobile emulation CDP touch Pan');
await touchPage.getByRole('button',{name:'Fit to View',exact:true}).tap(); await settle(touchPage);
await touchPage.locator('.hm-orgchart-employee').first().tap(); await touchPage.locator('dialog[open]').waitFor();
check(await touchPage.getByRole('button',{name:'직원 정보 닫기',exact:true}).count()===1,'Mobile emulation card tap opens named dialog');
await touchPage.getByRole('button',{name:'직원 정보 닫기',exact:true}).tap(); await settle(touchPage);
await touchPage.screenshot({path:path.join(output,'touch-emulation.png'),fullPage:true}); await touchContext.close();
check(errors.length===0,'No new console/runtime errors',errors);
}
} catch(error) { check(false,'Runner completed',error.stack); }
finally {
await fs.writeFile(path.join(output,'browser.json'),JSON.stringify({scope:'P5-4 change impact only',results,errors,measurements,notVerified:['Actual screen-reader speech','Physical mobile device','Other browser engines','Unsupported no-connection direction','Historical full regression'],summary:{pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length}},null,2));
await browser.close(); await new Promise(resolve=>server.close(resolve));
}
console.log(JSON.stringify({pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length,output}));
if(results.some(r=>r.status==='FAIL')) process.exitCode=1;