feat(org-chart): add core cards and fixed-layout integration

This commit is contained in:
shkim4 committed 2026-10-12 17:58:30 +09:00
1 parent d18e3c3380
commit 61c6f8d1f1
161 files changed
+13044 -1

No files matched your search

@@ -0,0 +1,133 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { selectAffiliationSummary, selectMajorOrganizationTotals, selectOrganizationPositionCounts, selectStatusGroups } from './content-selectors.js';
import { calculateSummary } from './summary.js';
import type { OrgChartDataset } from './types.js';
function fixture(): OrgChartDataset {
return {
schemaVersion: '0.1-draft',
organizations: [
{ id: 'root', parentId: null, name: '가상 본부', type: 'root', leaderId: 'leader', displayOrder: 0 },
{ id: 'team', parentId: 'root', name: '가상 팀', type: 'team', leaderId: null, displayOrder: 1 },
{ id: 'empty', parentId: 'root', name: '빈 가상 팀', type: 'team', leaderId: null, displayOrder: 2 },
],
employees: [
{ id: 'leader', organizationId: 'root', affiliationId: 'first', name: '가상 가', position: '대표', jobTitle: '본부장', profileImage: null, workStatus: 'active' },
{ id: 'sent', organizationId: 'team', affiliationId: 'second', name: '가상 나', position: '책임', jobTitle: null, profileImage: null, workStatus: 'dispatched' },
{ id: 'leave', organizationId: 'team', affiliationId: 'first', name: '가상 다', position: '책임', jobTitle: null, profileImage: null, workStatus: 'onLeave' },
],
affiliations: [{ id: 'first', name: '가상 관계사 가' }, { id: 'second', name: '가상 관계사 나' }, { id: 'zero', name: '가상 관계사 다' }],
metadata: { asOfDate: '2026-10-12', dataVersion: 'selector-test' },
};
}
test('position matrix uses direct primary membership, includes leaders and all work statuses', () => {
const data = fixture();
const matrix = selectOrganizationPositionCounts(data);
const summary = calculateSummary(data);
assert.deepEqual([...matrix.get('root')!], [['대표', 1]]);
assert.deepEqual([...matrix.get('team')!], [['책임', 2]]);
assert.equal(matrix.get('empty')!.size, 0);
assert.equal(matrix.get('root')!.get('책임'), undefined);
assert.equal(summary.raw.byOrganization.root!.inclusive, 3);
for (const organization of data.organizations) {
assert.equal([...matrix.get(organization.id)!.values()].reduce((sum, count) => sum + count, 0), summary.raw.byOrganization[organization.id]!.direct);
}
for (const [position, count] of Object.entries(summary.raw.byPosition)) {
assert.equal([...matrix.values()].reduce((sum, row) => sum + (row.get(position) ?? 0), 0), count);
}
assert.equal([...matrix.values()].flatMap(row => [...row.values()]).reduce((sum, count) => sum + count, 0), summary.raw.total);
assert.equal(summary.aggregation.status, 'unavailable');
});
test('duplicate IDs cannot inflate matrix or status groups and original Employee references survive', () => {
const data = fixture();
data.employees.push({ ...data.employees[1]!, organizationId: 'root', position: '대표' });
const summary = calculateSummary(data);
const matrix = selectOrganizationPositionCounts(data);
assert.equal(matrix.get('team')!.get('책임'), 2);
assert.equal(matrix.get('root')!.get('대표'), 1);
const groups = selectStatusGroups(data, summary);
assert.equal(groups[0]!.employees.length, 1);
assert.strictEqual(groups[0]!.employees[0], data.employees[1]);
assert.strictEqual(groups[1]!.employees[0], data.employees[2]);
assert.equal(summary.raw.total, 3);
});
test('opaque prototype-like organization IDs and position strings remain ordinary matrix keys', () => {
const data = fixture();
data.organizations[1]!.id = '__proto__';
data.employees[1]!.organizationId = '__proto__';
data.employees[2]!.organizationId = '__proto__';
data.employees[1]!.position = 'constructor';
data.employees[2]!.position = '__proto__';
const row = selectOrganizationPositionCounts(data).get('__proto__')!;
assert.equal(row.get('constructor'), 1);
assert.equal(row.get('__proto__'), 1);
});
test('affiliation summary preserves declared order, zero entries and original affiliation objects', () => {
const data = fixture();
const summary = calculateSummary(data);
const rows = selectAffiliationSummary(data, summary);
assert.deepEqual(rows.map(row => [row.affiliation.id, row.count]), [['first', 2], ['second', 1], ['zero', 0]]);
assert.strictEqual(rows[2]!.affiliation, data.affiliations[2]);
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), summary.raw.total);
});
test('status flags independently hide groups without changing Raw Count or affiliation totals', () => {
const data = fixture();
const summary = calculateSummary(data);
data.presentation = {
title: '가상 조직도', description: null, hubText: null, organizationOrder: [],
groups: { showEmployees: false, showSummary: false, showAffiliations: false, showDispatched: false, showOnLeave: true },
};
assert.deepEqual(selectStatusGroups(data, summary).map(group => group.status), ['onLeave']);
data.presentation.groups.showDispatched = true;
data.presentation.groups.showOnLeave = false;
assert.deepEqual(selectStatusGroups(data, summary).map(group => group.status), ['dispatched']);
data.presentation.groups.showDispatched = false;
assert.deepEqual(selectStatusGroups(data, summary), []);
assert.deepEqual(calculateSummary(data), summary);
assert.equal(selectAffiliationSummary(data, summary).length, 3);
});
test('zero employees retains all organization/affiliation rows and both empty status groups', () => {
const data = fixture();
data.employees = [];
data.organizations[0]!.leaderId = null;
const summary = calculateSummary(data);
assert.equal(selectOrganizationPositionCounts(data).size, 3);
assert.ok([...selectOrganizationPositionCounts(data).values()].every(row => row.size === 0));
assert.ok(selectAffiliationSummary(data, summary).every(row => row.count === 0));
assert.deepEqual(selectStatusGroups(data, summary).map(group => [group.status, group.count, group.employees.length]), [['dispatched', 0, 0], ['onLeave', 0, 0]]);
});
test('new JSON snapshot updates matrix, affiliations and status groups without a cached count', () => {
const data = fixture();
const before = calculateSummary(data);
data.employees[1]!.organizationId = 'root';
data.employees[1]!.position = '수석';
data.employees[1]!.affiliationId = 'zero';
data.employees[1]!.workStatus = 'active';
const after = calculateSummary(data);
const matrix = selectOrganizationPositionCounts(data);
assert.equal(matrix.get('root')!.get('수석'), 1);
assert.equal(matrix.get('team')!.get('책임'), 1);
assert.deepEqual(selectAffiliationSummary(data, after).map(row => row.count), [2, 0, 1]);
assert.equal(selectStatusGroups(data, after)[0]!.employees.length, 0);
assert.equal(before.raw.byStatus.dispatched, 1);
assert.equal(after.raw.total, before.raw.total);
});
test('major organization totals: root direct members then inclusive root children, summing to the raw total', () => {
const data = fixture();
data.organizations.push({ id: 'cell', parentId: 'team', name: '가상 셀', type: 'cell', leaderId: null, displayOrder: 3 });
data.employees.push({ id: 'member', organizationId: 'cell', affiliationId: 'zero', name: '가상 라', position: '선임', jobTitle: null, profileImage: null, workStatus: 'active' });
const summary = calculateSummary(data);
const rows = selectMajorOrganizationTotals(data, summary);
assert.deepEqual(rows.map(row => [row.organization.id, row.scope, row.count]), [['root', 'direct', 1], ['team', 'inclusive', 3], ['empty', 'inclusive', 0]]);
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), summary.raw.total);
assert.equal(rows.some(row => row.organization.id === 'cell'), false);
});
@@ -0,0 +1,79 @@
import { normalizeOrgChartDataset } from './normalize.js';
import type { Employee, OrgChartDataset, OrgChartSummary, Organization, WorkStatus } from './types.js';
/** Internal content selectors: these do not expand the package's public data API. */
function uniqueEmployees(data: OrgChartDataset): Employee[] {
const seen = new Set<string>();
return data.employees.filter(employee => {
if (seen.has(employee.id)) return false;
seen.add(employee.id);
return true;
});
}
/** Literal unique IDs in their primary organization, including leaders and all statuses.
* Inclusive organization counts stay in Summary; they must never be summed as a subtotal.
* Map keys safely retain opaque organization IDs and position names such as __proto__.
*/
export function selectOrganizationPositionCounts(data: OrgChartDataset): ReadonlyMap<string, ReadonlyMap<string, number>> {
const counts = new Map<string, Map<string, number>>();
for (const organization of data.organizations) counts.set(organization.id, new Map());
for (const employee of uniqueEmployees(data)) {
const row = counts.get(employee.organizationId);
if (row) row.set(employee.position, (row.get(employee.position) ?? 0) + 1);
}
return counts;
}
export function selectAffiliationSummary(data: OrgChartDataset, summary: OrgChartSummary) {
// Include all declared affiliations, including zero, without recounting employees.
return data.affiliations.map(affiliation => ({
affiliation,
count: summary.raw.byAffiliation[affiliation.id] ?? 0,
}));
}
export interface MajorOrganizationTotal {
organization: Organization;
/** 'direct' for a root's own members (e.g. its leader); 'inclusive' for a major organization. */
scope: 'direct' | 'inclusive';
count: number;
}
/** Compact totals for the read-only composition: each root's direct members, then
* each major (root child) organization including its cells/teams. Primary
* membership is single, so the rows are disjoint and add up to the raw total. */
export function selectMajorOrganizationTotals(data: OrgChartDataset, summary: OrgChartSummary): MajorOrganizationTotal[] {
const rows: MajorOrganizationTotal[] = [];
for (const root of normalizeOrgChartDataset(data).roots) {
const counts = summary.raw.byOrganization[root.organization.id];
if (!root.children.length) {
rows.push({ organization: root.organization, scope: 'inclusive', count: counts?.inclusive ?? 0 });
continue;
}
if (counts?.direct) rows.push({ organization: root.organization, scope: 'direct', count: counts.direct });
for (const child of root.children) {
rows.push({ organization: child.organization, scope: 'inclusive', count: summary.raw.byOrganization[child.organization.id]?.inclusive ?? 0 });
}
}
return rows;
}
export interface StatusContentGroup {
status: Extract<WorkStatus, 'dispatched' | 'onLeave'>;
employees: readonly Employee[];
count: number;
}
export function selectStatusGroups(data: OrgChartDataset, summary: OrgChartSummary): StatusContentGroup[] {
const employees = uniqueEmployees(data);
const groups: StatusContentGroup[] = [];
if (data.presentation?.groups.showDispatched !== false) {
groups.push({ status: 'dispatched', employees: employees.filter(employee => employee.workStatus === 'dispatched'), count: summary.raw.byStatus.dispatched });
}
if (data.presentation?.groups.showOnLeave !== false) {
groups.push({ status: 'onLeave', employees: employees.filter(employee => employee.workStatus === 'onLeave'), count: summary.raw.byStatus.onLeave });
}
// Cards retain original Employee references. A second display never changes Summary.
return groups;
}
+241
View File
@@ -0,0 +1,241 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
calculateSummary, JsonOrgChartDataSource, normalizeOrgChartDataset, OrgChartSourceError,
snapshotOrgChartDataset, validateOrgChartDataset, type ApprovedAggregationPolicy, type OrgChartDataset,
} from './index.js';
function fixture(): OrgChartDataset {
return {
schemaVersion: '0.1-draft',
organizations: [
{ id: 'root', parentId: null, name: '가상 본부', type: 'root', leaderId: 'a', displayOrder: 0 },
{ id: 'team-a', parentId: 'root', name: '가상 기획팀', type: 'team', leaderId: 'b', displayOrder: 2 },
{ id: 'team-b', parentId: 'root', name: '가상 운영팀', type: 'team', leaderId: null, displayOrder: 1 },
],
employees: [
{ id: 'a', organizationId: 'root', affiliationId: 'aff-a', name: '가상직원 가', position: '대표', jobTitle: '본부장', profileImage: null, workStatus: 'active' },
{ id: 'b', organizationId: 'team-a', affiliationId: 'aff-a', name: '가상직원 나', position: '책임', jobTitle: '팀장', profileImage: null, workStatus: 'active' },
{ id: 'c', organizationId: 'team-a', affiliationId: 'aff-b', name: '가상직원 다', position: '선임', jobTitle: null, profileImage: './profiles/virtual.png', workStatus: 'dispatched' },
{ id: 'd', organizationId: 'team-b', affiliationId: 'aff-b', name: '가상직원 라', position: '선임', jobTitle: null, profileImage: 'https://example.invalid/virtual.png', workStatus: 'onLeave' },
],
affiliations: [{ id: 'aff-a', name: '가상 관계사 A' }, { id: 'aff-b', name: '가상 관계사 B' }],
metadata: { asOfDate: '2026-10-12', dataVersion: 'test-1' },
};
}
function codes(input: unknown): string[] {
const result = validateOrgChartDataset(input);
assert.equal(result.valid, false);
return result.valid ? [] : result.issues.map(issue => issue.code);
}
const policy: ApprovedAggregationPolicy = {
policyVersion: 'approved-test-1', includedStatuses: ['active', 'dispatched'], activeStatuses: ['active'],
includeLeaders: true, membershipMode: 'primaryOnly',
};
test('minimal v0.1-draft dataset validates and owns an immutable copy', () => {
const original = fixture();
const result = validateOrgChartDataset(original);
assert.equal(result.valid, true);
if (!result.valid) return;
original.organizations[0]!.name = 'Changed';
assert.equal(result.data.organizations[0]!.name, '가상 본부');
assert.ok(Object.isFrozen(result.data.employees[0]));
});
test('version is required at top level; compatibility metadata mirror must agree', () => {
const original = fixture() as unknown as Record<string, unknown>;
delete original.schemaVersion;
assert.ok(codes(original).includes('SCHEMA_VERSION'));
const incompatible = fixture();
(incompatible.metadata as unknown as Record<string, unknown>).schemaVersion = 'future';
assert.ok(codes(incompatible).includes('SCHEMA_VERSION'));
});
test('required fields, nullability, whitespace and unknown fields are rejected', () => {
const original = fixture();
original.organizations[0]!.name = ' ';
original.employees[0]!.position = null as unknown as string;
(original.organizations[1] as unknown as Record<string, unknown>).nodeTemplate = '<script>alert(1)</script>';
assert.ok(codes(original).includes('REQUIRED_VALUE'));
assert.ok(codes(original).includes('UNKNOWN_FIELD'));
});
test('dates are real calendar dates and timestamps require valid clock/timezone', () => {
for (const date of ['2026-02-30', '2026-13-01', '2026-1-1']) {
const original = fixture(); original.metadata.asOfDate = date;
assert.ok(codes(original).includes('REQUIRED_VALUE'));
}
const original = fixture(); original.metadata.updatedAt = '2026-10-12T24:00:00Z';
assert.ok(codes(original).includes('REQUIRED_VALUE'));
original.metadata.updatedAt = '2026-10-12T11:23:45+09:00';
assert.equal(validateOrgChartDataset(original).valid, true);
});
test('zero employees is valid; zero organizations is a data error', () => {
const original = fixture(); original.employees = [];
original.organizations.forEach(org => { org.leaderId = null; });
assert.equal(validateOrgChartDataset(original).valid, true);
original.organizations = [];
assert.ok(codes(original).includes('EMPTY_ORGANIZATIONS'));
});
test('duplicate collection IDs never overwrite an earlier item', () => {
const original = fixture(); original.employees.push({ ...original.employees[0]! });
assert.ok(codes(original).includes('DUPLICATE_ID'));
});
test('employee, parent, affiliation and leader references are checked', () => {
const original = fixture();
original.employees[0]!.organizationId = 'missing';
original.employees[1]!.affiliationId = 'missing';
original.organizations[2]!.parentId = 'missing';
original.organizations[2]!.leaderId = 'missing';
const failures = codes(original);
for (const code of ['ORGANIZATION_REFERENCE', 'AFFILIATION_REFERENCE', 'PARENT_REFERENCE', 'LEADER_REFERENCE']) assert.ok(failures.includes(code));
});
test('leader cross-organization membership requires explicit business approval', () => {
const original = fixture(); original.organizations[2]!.leaderId = 'b';
assert.ok(codes(original).includes('LEADER_APPROVAL_REQUIRED'));
});
test('self-parent and multi-level cycles are rejected independently of sorting', () => {
const original = fixture(); original.organizations[0]!.parentId = 'team-a';
assert.ok(codes(original).includes('HIERARCHY_CYCLE'));
original.organizations[0]!.parentId = 'root';
assert.ok(codes(original).includes('HIERARCHY_CYCLE'));
});
test('unknown vocabularies are rejected without implicit status mapping', () => {
const original = fixture(); original.employees[0]!.workStatus = 'retired' as never;
original.organizations[0]!.type = 'unknown' as never;
assert.ok(codes(original).includes('INVALID_STATUS'));
assert.ok(codes(original).includes('INVALID_TYPE'));
});
test('relation endpoints, self-relations and duplicate directed relations are checked', () => {
const original = fixture();
original.relations = [
{ id: 'r1', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'support' },
{ id: 'r2', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'support' },
{ id: 'r3', sourceOrganizationId: 'missing', targetOrganizationId: 'missing', type: 'support' },
];
assert.ok(codes(original).includes('INVALID_RELATION'));
assert.ok(codes(original).includes('RELATION_REFERENCE'));
});
test('markup, executable URLs and identifier control characters are rejected', () => {
for (const image of ['javascript:alert(1)', 'data:image/svg+xml,<svg/>', '//host.invalid/x', 'file:///x', 'https://user:password@example.invalid/x', 'images\\x.png']) {
const original = fixture(); original.employees[0]!.profileImage = image;
assert.ok(codes(original).includes('UNSAFE_INPUT'));
}
const original = fixture(); original.employees[0]!.name = '<img src=x onerror=alert(1)>';
original.organizations[1]!.id += '\u202e';
assert.ok(codes(original).includes('UNSAFE_INPUT'));
});
test('normalization preserves hierarchy, forest roots and deterministic presentation order', () => {
const original = fixture();
original.organizations.push({ id: 'other-root', parentId: null, name: '추가 본부', type: 'root', leaderId: null, displayOrder: 1 });
const natural = normalizeOrgChartDataset(original);
assert.deepEqual(natural.roots.map(node => node.organization.id), ['root', 'other-root']);
assert.deepEqual(natural.roots[0]!.children.map(node => node.organization.id), ['team-b', 'team-a']);
original.presentation = {
title: '가상 조직도', description: null, hubText: null,
groups: { showEmployees: false, showSummary: true, showDispatched: false, showOnLeave: false, showAffiliations: false },
organizationOrder: ['team-a'],
};
assert.equal(validateOrgChartDataset(original).valid, true);
assert.deepEqual(normalizeOrgChartDataset(original).roots[0]!.children.map(node => node.organization.id), ['team-a', 'team-b']);
original.presentation.organizationOrder.push('missing');
assert.ok(codes(original).includes('PRESENTATION_REFERENCE'));
});
test('raw summary uses unique IDs, primary membership and position rather than job title', () => {
const original = fixture(); const summary = calculateSummary(original);
assert.equal(summary.raw.total, 4);
assert.deepEqual(summary.raw.byOrganization.root, { direct: 1, inclusive: 4 });
assert.deepEqual(summary.raw.byOrganization['team-a'], { direct: 2, inclusive: 2 });
assert.equal(summary.raw.byPosition['선임'], 2);
assert.equal(summary.raw.byPosition['팀장'], undefined);
assert.equal(summary.raw.byAffiliation['aff-b'], 2);
assert.deepEqual(summary.raw.byStatus, { active: 2, dispatched: 1, onLeave: 1 });
assert.deepEqual(summary.aggregation, { status: 'unavailable', reason: 'policyRequired' });
});
test('approved filtering changes business counts; raw counts and hidden groups remain unchanged', () => {
const original = fixture(); const result = calculateSummary(original, policy);
assert.equal(result.raw.total, 4);
assert.equal(result.aggregation.status, 'ready');
if (result.aggregation.status === 'ready') {
assert.equal(result.aggregation.counts.total, 3);
assert.equal(result.aggregation.counts.byOrganization.root!.inclusive, 3);
assert.equal(result.aggregation.counts.byStatus.onLeave, 0);
}
original.presentation = {
title: '가상 조직도', description: null, hubText: '설명 콘텐츠',
groups: { showEmployees: false, showSummary: false, showDispatched: false, showOnLeave: false, showAffiliations: false },
organizationOrder: ['team-b'],
};
original.relations = [{ id: 'rel-a', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'cooperation' }];
assert.deepEqual(calculateSummary(original, policy), result);
original.employees[2]!.organizationId = 'team-b';
original.employees[2]!.workStatus = 'active';
original.employees[2]!.affiliationId = 'aff-a';
const moved = calculateSummary(original);
assert.equal(moved.raw.byOrganization['team-a']!.direct, 1);
assert.equal(moved.raw.byOrganization['team-b']!.direct, 2);
assert.equal(moved.raw.byStatus.active, 3);
assert.equal(moved.raw.byAffiliation['aff-a'], 3);
});
test('ambiguous or invalid business policy stays unavailable', () => {
for (const invalid of [
{ ...policy, includeLeaders: false }, { ...policy, membershipMode: 'uniqueEmployee' },
{ ...policy, policyVersion: '' }, { ...policy, includedStatuses: ['active', 'active'] },
{ ...policy, includedStatuses: ['dispatched'] },
]) {
const result = calculateSummary(fixture(), invalid as ApprovedAggregationPolicy);
assert.deepEqual(result.aggregation, { status: 'unavailable', reason: 'unsupportedPolicy' });
}
});
test('prototype-like opaque IDs and positions cannot corrupt summary records', () => {
const original = fixture(); original.employees[0]!.position = '__proto__';
assert.equal(calculateSummary(original).raw.byPosition['__proto__'], 1);
});
test('source captures inline input once and returns owned copies', async () => {
const original = fixture(); const source = new JsonOrgChartDataSource(original);
original.metadata.dataVersion = 'consumer-mutated';
const first = await source.load() as OrgChartDataset;
assert.equal(first.metadata.dataVersion, 'test-1');
assert.notEqual(first, await source.load());
assert.ok(Object.isFrozen(first));
const snapshot = snapshotOrgChartDataset(original);
assert.notEqual(snapshot, original);
});
test('URL source passes cancellation signal and captures the response', async () => {
const controller = new AbortController(); let receivedSignal: AbortSignal | null | undefined;
const source = new JsonOrgChartDataSource('./virtual.json', { fetch: (async (_url, options) => {
receivedSignal = options?.signal;
return new Response(JSON.stringify(fixture()), { status: 200 });
}) as typeof fetch });
assert.equal(validateOrgChartDataset(await source.load({ signal: controller.signal })).valid, true);
assert.equal(receivedSignal, controller.signal);
});
test('default network source loads with a receiver-sensitive browser-native fetch', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async function(this: unknown) {
if (this !== globalThis) throw new TypeError('Illegal invocation');
return new Response(JSON.stringify(fixture()), { status: 200 });
} as typeof fetch;
try {
const source = new JsonOrgChartDataSource('./virtual.json');
assert.equal(validateOrgChartDataset(await source.load()).valid, true);
} finally {
globalThis.fetch = originalFetch;
}
});
test('source errors distinguish HTTP, parsing, unsafe URL and abort without leaking upstream text', async () => {
assert.throws(() => new JsonOrgChartDataSource('javascript:alert(1)'), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_URL');
const failed = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => { throw new Error('PRIVATE_SERVER_ERROR'); }) as typeof fetch });
await assert.rejects(failed.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_LOAD' && !error.message.includes('PRIVATE'));
const http = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => new Response('private', { status: 500 })) as typeof fetch });
await assert.rejects(http.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_LOAD');
const parse = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => new Response('{invalid')) as typeof fetch });
await assert.rejects(parse.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_PARSE');
const controller = new AbortController(); controller.abort();
await assert.rejects(failed.load({ signal: controller.signal }), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_ABORT');
});
test('late ignored abort response cannot become a successful source result', async () => {
const controller = new AbortController();
const source = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => {
controller.abort(); return new Response(JSON.stringify(fixture()));
}) as typeof fetch });
await assert.rejects(source.load({ signal: controller.signal }), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_ABORT');
});
+6
View File
@@ -0,0 +1,6 @@
export * from './types.js';
export { validateOrgChartDataset, isSafeImageUrl } from './validate.js';
export { normalizeOrgChartDataset } from './normalize.js';
export { calculateSummary } from './summary.js';
export { snapshotOrgChartDataset } from './snapshot.js';
export { JsonOrgChartDataSource, OrgChartSourceError, type OrgChartSourceErrorCode, type JsonOrgChartDataSourceOptions } from './source.js';
+27
View File
@@ -0,0 +1,27 @@
import type { Employee, NormalizedOrgChartDataset, OrganizationTreeNode, OrgChartDataset } from './types.js';
/** Pure derivation for validated datasets. Multiple roots remain a forest. */
export function normalizeOrgChartDataset(data: OrgChartDataset): NormalizedOrgChartDataset {
const organizationsById = new Map(data.organizations.map(org => [org.id, org]));
const employeesByOrganization = new Map<string, Employee[]>(data.organizations.map(org => [org.id, []]));
for (const employee of data.employees) employeesByOrganization.get(employee.organizationId)?.push(employee);
const nodes = new Map<string, OrganizationTreeNode>(data.organizations.map(org => [org.id, { organization: org, children: [] }]));
const roots: OrganizationTreeNode[] = [];
for (const node of nodes.values()) {
if (node.organization.parentId === null) roots.push(node);
else nodes.get(node.organization.parentId)?.children.push(node);
}
const order = new Map((data.presentation?.organizationOrder ?? []).map((id, index) => [id, index]));
const compare = (a: OrganizationTreeNode, b: OrganizationTreeNode) => {
const aOrder = order.get(a.organization.id) ?? Infinity;
const bOrder = order.get(b.organization.id) ?? Infinity;
if (aOrder !== bOrder) return aOrder < bOrder ? -1 : 1;
const difference = a.organization.displayOrder - b.organization.displayOrder;
if (difference) return difference;
// Code-unit order is deterministic across environments and independent of locale.
return a.organization.id < b.organization.id ? -1 : a.organization.id > b.organization.id ? 1 : 0;
};
roots.sort(compare);
for (const node of nodes.values()) node.children.sort(compare);
return { roots, organizationsById, employeesByOrganization };
}
+14
View File
@@ -0,0 +1,14 @@
/** Own a JSON-like input at the call boundary; never retain consumer objects. */
export function snapshotOrgChartDataset<T>(input: T): T {
const copy = structuredClone(input);
const pending: unknown[] = [copy];
const seen = new WeakSet<object>();
while (pending.length) {
const current = pending.pop();
if (current === null || typeof current !== 'object' || seen.has(current)) continue;
seen.add(current);
for (const child of Object.values(current)) pending.push(child);
Object.freeze(current);
}
return copy;
}
+61
View File
@@ -0,0 +1,61 @@
import { snapshotOrgChartDataset } from './snapshot.js';
import type { OrgChartDataSource } from './types.js';
export type OrgChartSourceErrorCode = 'SOURCE_LOAD' | 'SOURCE_PARSE' | 'SOURCE_ABORT' | 'SOURCE_URL' | 'SOURCE_SNAPSHOT';
export class OrgChartSourceError extends Error {
readonly path = 'dataSource';
constructor(readonly code: OrgChartSourceErrorCode, message: string) {
super(message);
this.name = 'OrgChartSourceError';
}
}
export interface JsonOrgChartDataSourceOptions { fetch?: typeof globalThis.fetch }
/** Inline JSON is captured at construction; URL sources load a fresh snapshot on each call. */
export class JsonOrgChartDataSource implements OrgChartDataSource {
private readonly inline: unknown;
private readonly url: string | undefined;
private readonly fetcher: typeof globalThis.fetch | undefined;
constructor(input: unknown, options: JsonOrgChartDataSourceOptions = {}) {
// Browser-native fetch may require the global Window receiver. Retaining it as
// an instance property must not accidentally call it with this source as `this`.
this.fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
if (typeof input === 'string' || input instanceof URL) {
const url = String(input);
if (!safeSourceUrl(url)) throw new OrgChartSourceError('SOURCE_URL', 'Data source URL must use HTTP(S) or a relative JSON path.');
this.url = url;
} else {
try { this.inline = snapshotOrgChartDataset(input); }
catch { throw new OrgChartSourceError('SOURCE_SNAPSHOT', 'Data source input must be cloneable JSON.'); }
}
}
async load(options: { signal?: AbortSignal } = {}): Promise<unknown> {
const signal = options.signal;
const aborted = () => { if (signal?.aborted) throw new OrgChartSourceError('SOURCE_ABORT', 'Data source request was cancelled.'); };
aborted();
if (this.url === undefined) return snapshotOrgChartDataset(this.inline);
if (!this.fetcher) throw new OrgChartSourceError('SOURCE_LOAD', 'No fetch implementation is available.');
let response: Response;
try { response = await this.fetcher(this.url, { signal }); }
catch {
aborted();
throw new OrgChartSourceError('SOURCE_LOAD', 'Data source could not be loaded.');
}
aborted();
if (!response.ok) throw new OrgChartSourceError('SOURCE_LOAD', 'Data source returned an unsuccessful response.');
let payload: unknown;
try { payload = await response.json(); }
catch {
aborted();
throw new OrgChartSourceError('SOURCE_PARSE', 'Data source response is not valid JSON.');
}
aborted();
try { return snapshotOrgChartDataset(payload); }
catch { throw new OrgChartSourceError('SOURCE_SNAPSHOT', 'Data source response must be cloneable JSON.'); }
}
}
function safeSourceUrl(value: string): boolean {
if (!value.trim() || /[\s\\<>\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(value) || value.startsWith('//')) return false;
if (!/^[a-z][a-z\d+.-]*:/i.test(value)) return !/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(value);
try { const parsed = new URL(value); return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password; }
catch { return false; }
}
+56
View File
@@ -0,0 +1,56 @@
import { normalizeOrgChartDataset } from './normalize.js';
import type { ApprovedAggregationPolicy, Employee, OrgChartDataset, OrgChartSummary, SummaryCounts, WorkStatus } from './types.js';
const statuses: WorkStatus[] = ['active', 'dispatched', 'onLeave'];
function supportedPolicy(policy: ApprovedAggregationPolicy): boolean {
const validStatuses = (values: unknown): values is WorkStatus[] => Array.isArray(values)
&& values.every(value => statuses.includes(value)) && new Set(values).size === values.length;
return !!policy && typeof policy.policyVersion === 'string' && !!policy.policyVersion.trim()
&& !/[\u0000-\u001f<>]/.test(policy.policyVersion)
&& validStatuses(policy.includedStatuses) && validStatuses(policy.activeStatuses)
&& policy.activeStatuses.every(status => policy.includedStatuses.includes(status))
&& policy.includeLeaders === true && policy.membershipMode === 'primaryOnly';
}
function count(data: OrgChartDataset, employees: readonly Employee[]): SummaryCounts {
const counts: SummaryCounts = {
total: 0,
byOrganization: Object.create(null) as SummaryCounts['byOrganization'],
byPosition: Object.create(null) as SummaryCounts['byPosition'],
byAffiliation: Object.create(null) as SummaryCounts['byAffiliation'],
byStatus: { active: 0, dispatched: 0, onLeave: 0 },
};
for (const org of data.organizations) counts.byOrganization[org.id] = { direct: 0, inclusive: 0 };
for (const affiliation of data.affiliations) counts.byAffiliation[affiliation.id] = 0;
const seen = new Set<string>();
for (const employee of employees) {
if (seen.has(employee.id)) continue;
seen.add(employee.id);
counts.total += 1;
counts.byOrganization[employee.organizationId]!.direct += 1;
counts.byPosition[employee.position] = (counts.byPosition[employee.position] ?? 0) + 1;
counts.byAffiliation[employee.affiliationId] = (counts.byAffiliation[employee.affiliationId] ?? 0) + 1;
counts.byStatus[employee.workStatus] += 1;
}
// Single primary membership makes child totals disjoint; compute a postorder iteratively.
const stack = [...normalizeOrgChartDataset(data).roots];
const traversal = [];
while (stack.length) {
const node = stack.pop()!;
traversal.push(node);
stack.push(...node.children);
}
for (let index = traversal.length - 1; index >= 0; index--) {
const node = traversal[index]!;
counts.byOrganization[node.organization.id]!.inclusive = counts.byOrganization[node.organization.id]!.direct
+ node.children.reduce((sum, child) => sum + counts.byOrganization[child.organization.id]!.inclusive, 0);
}
return counts;
}
/** Raw counts are literal unique employee IDs, independent of presentation or business totals. */
export function calculateSummary(data: OrgChartDataset, policy?: ApprovedAggregationPolicy): OrgChartSummary {
const raw = count(data, data.employees);
if (!policy) return { raw, aggregation: { status: 'unavailable', reason: 'policyRequired' } };
if (!supportedPolicy(policy)) return { raw, aggregation: { status: 'unavailable', reason: 'unsupportedPolicy' } };
const selected = data.employees.filter(employee => policy.includedStatuses.includes(employee.workStatus));
return { raw, aggregation: { status: 'ready', policyVersion: policy.policyVersion, counts: count(data, selected) } };
}
+96
View File
@@ -0,0 +1,96 @@
export const SCHEMA_VERSION = '0.1-draft' as const;
export type ID = string;
export type OrganizationType = 'root' | 'division' | 'office' | 'div' | 'cell' | 'team';
export type WorkStatus = 'active' | 'dispatched' | 'onLeave';
export type RelationType = 'cooperation' | 'support';
export interface Organization {
id: ID;
parentId: ID | null;
name: string;
type: OrganizationType;
leaderId: ID | null;
displayOrder: number;
}
export interface Employee {
id: ID;
organizationId: ID;
affiliationId: ID;
name: string;
position: string;
jobTitle: string | null;
profileImage: string | null;
workStatus: WorkStatus;
}
export interface Affiliation { id: ID; name: string }
export interface OrganizationRelation {
id: ID;
sourceOrganizationId: ID;
targetOrganizationId: ID;
type: RelationType;
}
export interface PresentationConfig {
title: string;
description: string | null;
hubText: string | null;
groups: {
showEmployees: boolean;
showSummary: boolean;
showDispatched: boolean;
showOnLeave: boolean;
showAffiliations: boolean;
};
organizationOrder: ID[];
}
export interface Metadata {
/** Compatibility mirror; the top-level schemaVersion is authoritative. */
schemaVersion?: typeof SCHEMA_VERSION;
asOfDate: string;
dataVersion: string;
updatedAt?: string | null;
updateNote?: string | null;
}
export interface OrgChartDataset {
schemaVersion: typeof SCHEMA_VERSION;
organizations: Organization[];
employees: Employee[];
affiliations: Affiliation[];
relations?: OrganizationRelation[];
presentation?: PresentationConfig;
metadata: Metadata;
}
export interface OrgChartDataSource {
load(options?: { signal?: AbortSignal }): Promise<unknown>;
}
export interface ValidationIssue { code: string; path: string; message: string }
export type ValidationResult =
| { valid: true; data: OrgChartDataset }
| { valid: false; issues: ValidationIssue[] };
export interface OrganizationTreeNode {
organization: Organization;
children: OrganizationTreeNode[];
}
export interface NormalizedOrgChartDataset {
roots: OrganizationTreeNode[];
organizationsById: ReadonlyMap<ID, Organization>;
employeesByOrganization: ReadonlyMap<ID, readonly Employee[]>;
}
export interface SummaryCounts {
total: number;
byOrganization: Record<ID, { direct: number; inclusive: number }>;
byPosition: Record<string, number>;
byAffiliation: Record<ID, number>;
byStatus: Record<WorkStatus, number>;
}
export interface ApprovedAggregationPolicy {
policyVersion: string;
includedStatuses: WorkStatus[];
activeStatuses: WorkStatus[];
includeLeaders: boolean;
membershipMode: 'primaryOnly' | 'uniqueEmployee';
}
export type AggregationPolicy = ApprovedAggregationPolicy;
export type AggregationResult =
| { status: 'unavailable'; reason: 'policyRequired' | 'unsupportedPolicy' }
| { status: 'ready'; policyVersion: string; counts: SummaryCounts };
export interface OrgChartSummary { raw: SummaryCounts; aggregation: AggregationResult }
+145
View File
@@ -0,0 +1,145 @@
import { SCHEMA_VERSION, type OrgChartDataset, type ValidationIssue, type ValidationResult } from './types.js';
import { snapshotOrgChartDataset } from './snapshot.js';
const organizationTypes = ['root', 'division', 'office', 'div', 'cell', 'team'];
const statuses = ['active', 'dispatched', 'onLeave'];
const relationTypes = ['cooperation', 'support'];
const unsafeText = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]|<\/?[a-z][^>]*>|(?:javascript|vbscript)\s*:/i;
type RecordValue = Record<string, unknown>;
function record(input: unknown): input is RecordValue {
return !!input && typeof input === 'object' && !Array.isArray(input)
&& (Object.getPrototypeOf(input) === Object.prototype || Object.getPrototypeOf(input) === null);
}
export function isSafeImageUrl(input: string): boolean {
if (!input.trim() || unsafeText.test(input) || /\s|\\/.test(input)) return false;
if (/^[a-z][a-z\d+.-]*:/i.test(input)) {
try { const url = new URL(input); return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password; }
catch { return false; }
}
// Protocol-relative URLs inherit network origins; require an explicit HTTP(S) URL instead.
return !input.startsWith('//') && !/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(input);
}
function validDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const parsed = new Date(value);
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}
function validTimestamp(value: string): boolean {
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/.exec(value);
return !!match && validDate(match[1]!) && Number(match[2]) < 24 && Number(match[3]) < 60
&& Number(match[4]) < 60 && (!match[6] || Number(match[6]) < 24) && (!match[7] || Number(match[7]) < 60)
&& Number.isFinite(Date.parse(value));
}
/** Strict contract validation. Messages contain no input values or server details. */
export function validateOrgChartDataset(input: unknown): ValidationResult {
const issues: ValidationIssue[] = [];
const issue = (code: string, path: string, message: string) => issues.push({ code, path, message });
const object = (value: unknown, path: string, keys: string[]): value is RecordValue => {
if (!record(value)) { issue('REQUIRED_VALUE', path, 'Expected a plain JSON object.'); return false; }
for (const key of Object.keys(value)) if (!keys.includes(key)) issue('UNKNOWN_FIELD', `${path}.${key}`, 'Field is not part of this contract.');
return true;
};
const text = (value: unknown, path: string, nullable = false): boolean => {
if (nullable && value === null) return true;
if (typeof value !== 'string' || !value.trim()) { issue('REQUIRED_VALUE', path, 'Expected a non-empty text value.'); return false; }
if (unsafeText.test(value)) { issue('UNSAFE_INPUT', path, 'Text contains unsupported executable markup or control characters.'); return false; }
return true;
};
const choice = (value: unknown, path: string, allowed: string[], code: string) => {
if (typeof value !== 'string' || !allowed.includes(value)) issue(code, path, 'Value is outside the supported contract vocabulary.');
};
const collection = (value: unknown, path: string, keys: string[], inspect: (item: RecordValue, path: string) => void, optional = false) => {
if (optional && value === undefined) return;
if (!Array.isArray(value)) { issue('REQUIRED_VALUE', path, 'Expected an array.'); return; }
const ids = new Set<string>();
value.forEach((item, index) => {
const location = `${path}[${index}]`;
if (!object(item, location, keys)) return;
if (text(item.id, `${location}.id`)) {
if (ids.has(item.id as string)) issue('DUPLICATE_ID', `${location}.id`, 'ID must be unique within its collection.');
ids.add(item.id as string);
}
inspect(item, location);
});
};
if (!object(input, '$', ['schemaVersion', 'organizations', 'employees', 'affiliations', 'relations', 'presentation', 'metadata'])) return { valid: false, issues };
if (input.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.schemaVersion', 'Unsupported or missing schema version.');
collection(input.organizations, '$.organizations', ['id', 'parentId', 'name', 'type', 'leaderId', 'displayOrder'], (org, path) => {
text(org.parentId, `${path}.parentId`, true); text(org.name, `${path}.name`); text(org.leaderId, `${path}.leaderId`, true);
choice(org.type, `${path}.type`, organizationTypes, 'INVALID_TYPE');
if (!Number.isSafeInteger(org.displayOrder) || (org.displayOrder as number) < 0) issue('REQUIRED_VALUE', `${path}.displayOrder`, 'Expected a non-negative safe integer.');
});
if (Array.isArray(input.organizations) && input.organizations.length === 0) issue('EMPTY_ORGANIZATIONS', '$.organizations', 'At least one organization is required.');
collection(input.employees, '$.employees', ['id', 'organizationId', 'affiliationId', 'name', 'position', 'jobTitle', 'profileImage', 'workStatus'], (employee, path) => {
for (const key of ['organizationId', 'affiliationId', 'name', 'position']) text(employee[key], `${path}.${key}`);
text(employee.jobTitle, `${path}.jobTitle`, true);
if (text(employee.profileImage, `${path}.profileImage`, true) && typeof employee.profileImage === 'string' && !isSafeImageUrl(employee.profileImage)) issue('UNSAFE_INPUT', `${path}.profileImage`, 'Image URL must use HTTP(S) or a safe relative path.');
choice(employee.workStatus, `${path}.workStatus`, statuses, 'INVALID_STATUS');
});
collection(input.affiliations, '$.affiliations', ['id', 'name'], (affiliation, path) => { text(affiliation.name, `${path}.name`); });
collection(input.relations, '$.relations', ['id', 'sourceOrganizationId', 'targetOrganizationId', 'type'], (relation, path) => {
text(relation.sourceOrganizationId, `${path}.sourceOrganizationId`); text(relation.targetOrganizationId, `${path}.targetOrganizationId`);
choice(relation.type, `${path}.type`, relationTypes, 'INVALID_RELATION');
}, true);
if (object(input.metadata, '$.metadata', ['schemaVersion', 'asOfDate', 'dataVersion', 'updatedAt', 'updateNote'])) {
const meta = input.metadata;
if (meta.schemaVersion !== undefined && meta.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.metadata.schemaVersion', 'Compatibility schema version must match the top-level version.');
if (text(meta.asOfDate, '$.metadata.asOfDate') && !validDate(meta.asOfDate as string)) issue('REQUIRED_VALUE', '$.metadata.asOfDate', 'Expected a real calendar date in YYYY-MM-DD format.');
text(meta.dataVersion, '$.metadata.dataVersion');
if (meta.updatedAt !== undefined && text(meta.updatedAt, '$.metadata.updatedAt', true) && typeof meta.updatedAt === 'string' && !validTimestamp(meta.updatedAt)) issue('REQUIRED_VALUE', '$.metadata.updatedAt', 'Expected an RFC 3339 timestamp with timezone.');
if (meta.updateNote !== undefined) text(meta.updateNote, '$.metadata.updateNote', true);
}
if (input.presentation !== undefined && object(input.presentation, '$.presentation', ['title', 'description', 'hubText', 'groups', 'organizationOrder'])) {
const presentation = input.presentation;
text(presentation.title, '$.presentation.title'); text(presentation.description, '$.presentation.description', true); text(presentation.hubText, '$.presentation.hubText', true);
const flags = ['showEmployees', 'showSummary', 'showDispatched', 'showOnLeave', 'showAffiliations'];
if (object(presentation.groups, '$.presentation.groups', flags)) for (const flag of flags) if (typeof presentation.groups[flag] !== 'boolean') issue('REQUIRED_VALUE', `$.presentation.groups.${flag}`, 'Expected a boolean.');
if (!Array.isArray(presentation.organizationOrder)) issue('REQUIRED_VALUE', '$.presentation.organizationOrder', 'Expected an array of organization IDs.');
else presentation.organizationOrder.forEach((id, index) => text(id, `$.presentation.organizationOrder[${index}]`));
}
if (issues.length) return { valid: false, issues };
const data = input as unknown as OrgChartDataset;
const organizations = new Map(data.organizations.map(org => [org.id, org]));
const employees = new Map(data.employees.map(employee => [employee.id, employee]));
const affiliations = new Set(data.affiliations.map(affiliation => affiliation.id));
data.organizations.forEach((org, index) => {
if (org.parentId !== null && !organizations.has(org.parentId)) issue('PARENT_REFERENCE', `$.organizations[${index}].parentId`, 'Parent organization does not exist.');
if (org.leaderId !== null) {
const leader = employees.get(org.leaderId);
if (!leader) issue('LEADER_REFERENCE', `$.organizations[${index}].leaderId`, 'Leader employee does not exist.');
else if (leader.organizationId !== org.id) issue('LEADER_APPROVAL_REQUIRED', `$.organizations[${index}].leaderId`, 'Leader membership outside this organization requires an approved business policy.');
}
});
data.employees.forEach((employee, index) => {
if (!organizations.has(employee.organizationId)) issue('ORGANIZATION_REFERENCE', `$.employees[${index}].organizationId`, 'Employee organization does not exist.');
if (!affiliations.has(employee.affiliationId)) issue('AFFILIATION_REFERENCE', `$.employees[${index}].affiliationId`, 'Employee affiliation does not exist.');
});
const checked = new Set<string>();
for (const org of data.organizations) {
if (checked.has(org.id)) continue;
const chain = new Set<string>();
let current: typeof org | undefined = org;
while (current && !checked.has(current.id)) {
if (chain.has(current.id)) { issue('HIERARCHY_CYCLE', '$.organizations', 'Organization parent references contain a cycle.'); break; }
chain.add(current.id);
current = current.parentId === null ? undefined : organizations.get(current.parentId);
}
for (const id of chain) checked.add(id);
}
const relationKeys = new Set<string>();
data.relations?.forEach((relation, index) => {
if (!organizations.has(relation.sourceOrganizationId) || !organizations.has(relation.targetOrganizationId)) issue('RELATION_REFERENCE', `$.relations[${index}]`, 'Relation endpoint organization does not exist.');
const key = JSON.stringify([relation.sourceOrganizationId, relation.targetOrganizationId, relation.type]);
if (relation.sourceOrganizationId === relation.targetOrganizationId || relationKeys.has(key)) issue('INVALID_RELATION', `$.relations[${index}]`, 'Self-relations and duplicate directed relations are unsupported.');
relationKeys.add(key);
});
const orderIds = new Set<string>();
data.presentation?.organizationOrder.forEach((id, index) => {
if (!organizations.has(id) || orderIds.has(id)) issue('PRESENTATION_REFERENCE', `$.presentation.organizationOrder[${index}]`, 'Presentation order requires existing, unique organization IDs.');
orderIds.add(id);
});
if (issues.length) return { valid: false, issues };
try { return { valid: true, data: snapshotOrgChartDataset(data) }; }
catch { return { valid: false, issues: [{ code: 'UNSAFE_INPUT', path: '$', message: 'Input must be a cloneable JSON dataset.' }] }; }
}