feat(org-chart): finalize chart features and AI generation rules

This commit is contained in:
shkim4 committed 2026-10-13 14:39:42 +09:00
1 parent 7614507583
commit d0828a7ff4
80 files changed
+8152 -221

No files matched your search

@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fixture from '../../../../examples/org-chart/data-expanded-large.json';
import { companyColorPresets, type CompanyId } from '../company-color-presets.js';
import {
calculateSummary, createOrgChartExampleDataset, JsonOrgChartDataSource,
normalizeOrgChartDataset, snapshotOrgChartDataset, validateOrgChartDataset,
type OrgChartDataset, type OrgChartExampleEmployeeCount,
} from './index.js';
const base = fixture as OrgChartDataset;
const companies = Object.keys(companyColorPresets) as CompanyId[];
const sizes: OrgChartExampleEmployeeCount[] = [20, 100, 300];
function assertContract(dataset: OrgChartDataset) {
const result = validateOrgChartDataset(dataset);
assert.equal(result.valid, true, result.valid ? undefined : JSON.stringify(result.issues));
assert.equal(new Set(dataset.employees.map(employee => employee.id)).size, dataset.employees.length);
assert.equal(new Set(dataset.organizations.map(org => org.id)).size, dataset.organizations.length);
assert.equal(new Set(dataset.affiliations.map(affiliation => affiliation.id)).size, dataset.affiliations.length);
const summary = calculateSummary(dataset).raw;
assert.equal(summary.total, dataset.employees.length, 'leaders count once as employees');
const pending = normalizeOrgChartDataset(dataset).roots.map(node => ({ node, depth: 1 }));
let maximumDepth = 0;
while (pending.length) {
const { node, depth } = pending.pop()!;
maximumDepth = Math.max(maximumDepth, depth);
pending.push(...node.children.map(child => ({ node: child, depth: depth + 1 })));
}
assert.ok(maximumDepth >= 3);
assert.ok(new Set(Object.values(summary.byOrganization).map(value => value.direct)).size > 1, 'unequal organization membership');
}
for (const employeeCount of sizes) {
test(`total ${employeeCount} contains exactly that many unique employees across all seven preset companies`, () => {
const dataset = createOrgChartExampleDataset(base, { scope: 'total', employeeCount });
assertContract(dataset);
assert.equal(dataset.employees.length, employeeCount);
assert.deepEqual(dataset.affiliations.map(item => item.id), companies);
for (const companyId of companies) assert.ok(dataset.employees.some(employee => employee.affiliationId === companyId));
});
test(`per-company ${employeeCount} supports every company, pairs, and the full seven-company selection`, () => {
for (const companyId of companies) {
const dataset = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount, companyIds: [companyId] });
assertContract(dataset);
assert.equal(dataset.employees.length, employeeCount);
assert.ok(dataset.organizations.every(org => org.companyId === companyId));
assert.ok(dataset.employees.every(employee => employee.affiliationId === companyId));
}
for (const companyIds of [['hanmac', 'saman'], companies] as CompanyId[][]) {
const dataset = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount, companyIds });
assertContract(dataset);
assert.equal(dataset.employees.length, employeeCount * companyIds.length);
for (const id of companyIds) assert.equal(dataset.employees.filter(employee => employee.affiliationId === id).length, employeeCount);
}
});
}
test('total company filtering preserves company allocation without redistribution or lost ancestors', () => {
for (const employeeCount of sizes) {
const complete = createOrgChartExampleDataset(base, { scope: 'total', employeeCount });
const selected = createOrgChartExampleDataset(base, { scope: 'total', employeeCount, companyIds: ['saman', 'hanmac'] });
assertContract(selected);
assert.deepEqual(selected.employees, complete.employees.filter(employee => ['hanmac', 'saman'].includes(employee.affiliationId)));
assert.deepEqual(selected.organizations.map(org => org.id), complete.organizations.filter(org => ['hanmac', 'saman'].includes(org.companyId!)).map(org => org.id));
}
});
test('total 300 reuses every P5-3 large employee record and the original organization IDs', () => {
const dataset = createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 300 });
const originalById = new Map(base.employees.map(employee => [employee.id, employee]));
assert.equal(dataset.employees.length, base.employees.length);
for (const employee of dataset.employees) assert.deepEqual(employee, originalById.get(employee.id));
assert.deepEqual(dataset.organizations, base.organizations);
});
test('selections are deterministic sets and snapshots never mutate the source or another result', async () => {
const before = structuredClone(base);
const frozen = snapshotOrgChartDataset(base);
const first = createOrgChartExampleDataset(frozen, { scope: 'per-company', employeeCount: 100, companyIds: ['saman', 'hanmac', 'saman'] });
const equivalent = createOrgChartExampleDataset(frozen, { scope: 'per-company', employeeCount: 100, companyIds: ['hanmac', 'saman'] });
assert.deepEqual(first, equivalent);
assert.notEqual(first, equivalent);
assert.notEqual(first.employees[0], equivalent.employees[0]);
assert.ok(Object.isFrozen(first) && Object.isFrozen(first.organizations) && Object.isFrozen(first.employees[0]));
assert.throws(() => { first.employees[0]!.name = 'changed'; }, TypeError);
assert.deepEqual(base, before);
assert.deepEqual(await new JsonOrgChartDataSource(first).load(), first);
const single = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount: 100, companyIds: ['saman'] });
assert.deepEqual(single.employees, first.employees.filter(employee => employee.affiliationId === 'saman'));
});
test('expansion resolves source IDs that already use the generated employee namespace', () => {
const template = structuredClone(base);
const employee = template.employees.find(employee => employee.affiliationId === 'hanmac' && !template.organizations.some(org => org.leaderId === employee.id))!;
employee.id = 'p7-virtual-hanmac-050';
const dataset = createOrgChartExampleDataset(template, { scope: 'per-company', employeeCount: 100 });
assertContract(dataset);
assert.ok(dataset.employees.some(employee => employee.id === 'p7-virtual-hanmac-050-example'));
});
test('invalid scale, company IDs, empty view selection, and incomplete templates fail explicitly', () => {
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'invalid' as 'total', employeeCount: 20 }), /scope/);
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 99 as 20 }), /employeeCount/);
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 20, companyIds: ['unknown' as CompanyId] }), /presets/);
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 20, companyIds: [] }), /empty view selection/);
const incomplete = structuredClone(base);
incomplete.employees = incomplete.employees.filter(employee => employee.affiliationId !== 'baron');
incomplete.organizations.filter(org => org.companyId === 'baron').forEach(org => { org.leaderId = null; });
assert.throws(() => createOrgChartExampleDataset(incomplete, { scope: 'total', employeeCount: 20 }), /all seven/);
});
+124
View File
@@ -0,0 +1,124 @@
import { companyColorPresets, type CompanyId } from '../company-color-presets.js';
import { normalizeOrgChartDataset } from './normalize.js';
import { snapshotOrgChartDataset } from './snapshot.js';
import { validateOrgChartDataset } from './validate.js';
import type { Employee, OrgChartDataset, Organization } from './types.js';
export type OrgChartExampleScope = 'total' | 'per-company';
export type OrgChartExampleEmployeeCount = 20 | 100 | 300;
export interface OrgChartExampleSelection {
/** Total is allocated across all seven companies before company filtering. */
scope: OrgChartExampleScope;
employeeCount: OrgChartExampleEmployeeCount;
/** Omission selects all companies. An empty selection belongs to view state. */
companyIds?: readonly CompanyId[];
}
const companyIds = Object.keys(companyColorPresets) as CompanyId[];
/** Deterministic virtual examples derived from an existing seven-company fixture.
* This provider is independent of layout, React and popup policy. Use its result
* with the existing JsonOrgChartDataSource; no production/API source is replaced.
*
* The P5-3 large fixture's hierarchy and employee records are reused. Larger
* company examples add virtual, company-namespaced employees. Neither the fixture
* nor a previously returned dataset is modified. Total-company selection never
* redistributes unselected companies' staff into a remaining company.
*/
export function createOrgChartExampleDataset(template: OrgChartDataset, selection: OrgChartExampleSelection): OrgChartDataset {
if (!selection || !['total', 'per-company'].includes(selection.scope)) throw new RangeError('Example scope must be total or per-company.');
if (![20, 100, 300].includes(selection.employeeCount)) throw new RangeError('Example employeeCount must be 20, 100 or 300.');
const requested = selection.companyIds ?? companyIds;
if (!Array.isArray(requested) || !requested.length) throw new RangeError('Example data requires at least one company; use an empty view selection to hide all companies.');
if (requested.some(id => !Object.hasOwn(companyColorPresets, id))) throw new RangeError('Example companyIds require registered company presets.');
// Preset order makes equivalent set selections identical, including IDs/order.
const selected = companyIds.filter(id => requested.includes(id));
const validation = validateOrgChartDataset(template);
if (!validation.valid) throw new TypeError('Example template must be a valid OrgChart dataset.');
const base = validation.data;
const organizationCompanies = resolveTemplateCompanies(base);
const rosters = new Map<CompanyId, Employee[]>(companyIds.map(id => [id, []]));
for (const employee of base.employees) {
const company = organizationCompanies.get(employee.organizationId)!;
if (employee.affiliationId !== company) throw new TypeError('Example employees must belong to their organization company affiliation.');
rosters.get(company)!.push(employee);
}
if (companyIds.some(id => !rosters.get(id)!.length)) throw new TypeError('Example template must contain employees from all seven companies.');
const counts = selection.scope === 'total'
? allocateTotal(selection.employeeCount, companyIds.map(id => rosters.get(id)!.length))
: companyIds.map(() => selection.employeeCount);
const chosen = new Set<CompanyId>(selected);
const organizations: Organization[] = base.organizations.filter(org => chosen.has(organizationCompanies.get(org.id)!))
.map(org => ({ ...org, companyId: organizationCompanies.get(org.id)! }));
const employees: Employee[] = [];
const existingIds = new Set(base.employees.map(employee => employee.id));
const organizationPool = new Map<CompanyId, Organization[]>(selected.map(id => [id, organizations.filter(org => org.companyId === id && org.parentId !== null)]));
for (const id of selected) {
const roster = rosters.get(id)!;
const count = counts[companyIds.indexOf(id)]!;
employees.push(...roster.slice(0, count).map(employee => ({ ...employee })));
// Unequal weighted placement reuses the original tree instead of cloning it.
const pool = organizationPool.get(id)!;
const weighted = (pool.length ? pool : organizations.filter(org => org.companyId === id))
.flatMap((org, index) => Array.from({ length: 1 + index % 4 }, () => org));
for (let index = roster.length; index < count; index++) {
let employeeId = `p7-virtual-${id}-${String(index + 1).padStart(3, '0')}`;
// A caller fixture may already use our namespace. Resolve collisions without
// changing the input or tying identity to the other selected companies.
while (existingIds.has(employeeId)) employeeId += '-example';
existingIds.add(employeeId);
const organization = weighted[(index - roster.length) % weighted.length]!;
employees.push({
id: employeeId, organizationId: organization.id, affiliationId: id,
name: `가상직원 ${companyColorPresets[id].companyName} ${String(index + 1).padStart(3, '0')}`,
position: ['사원', '선임', '책임', '수석'][index % 4]!, jobTitle: null,
profileImage: null, workStatus: 'active',
});
}
}
const employeeIds = new Set(employees.map(employee => employee.id));
for (const organization of organizations) if (organization.leaderId && !employeeIds.has(organization.leaderId)) organization.leaderId = null;
const organizationIds = new Set(organizations.map(org => org.id));
return snapshotOrgChartDataset({
...base, organizations, employees,
affiliations: base.affiliations.filter(affiliation => chosen.has(affiliation.id as CompanyId)),
...(base.relations ? { relations: base.relations.filter(relation => organizationIds.has(relation.sourceOrganizationId) && organizationIds.has(relation.targetOrganizationId)) } : {}),
...(base.presentation ? { presentation: { ...base.presentation, organizationOrder: base.presentation.organizationOrder.filter(id => organizationIds.has(id)) } } : {}),
metadata: { ...base.metadata, dataVersion: `p7-example-${selection.scope}-${selection.employeeCount}-${selected.join('_')}`, updateNote: '가상 예시 데이터 · 운영 정보 아님' },
});
}
function resolveTemplateCompanies(data: OrgChartDataset): Map<string, CompanyId> {
const employees = new Map(data.employees.map(employee => [employee.id, employee]));
const result = new Map<string, CompanyId>();
const pending = normalizeOrgChartDataset(data).roots.map(node => ({ node, inherited: undefined as CompanyId | undefined }));
while (pending.length) {
const { node, inherited } = pending.pop()!;
const company = node.organization.companyId ?? inherited ?? (node.organization.leaderId ? employees.get(node.organization.leaderId)?.affiliationId : undefined);
if (!company || !Object.hasOwn(companyColorPresets, company) || (inherited && company !== inherited)) throw new TypeError('Example template requires a consistent registered company on each organization tree.');
result.set(node.organization.id, company as CompanyId);
pending.push(...node.children.map(child => ({ node: child, inherited: company as CompanyId })));
}
return result;
}
/** Largest-remainder allocation, with at least one employee in every company. */
function allocateTotal(total: number, weights: number[]): number[] {
const sum = weights.reduce((value, weight) => value + weight, 0);
const targets = weights.map(weight => total * weight / sum);
const counts = targets.map(target => Math.max(1, Math.floor(target)));
let current = counts.reduce((value, count) => value + count, 0);
while (current !== total) {
const adding = current < total;
let candidate = -1;
let greatest = -Infinity;
counts.forEach((count, index) => {
if (!adding && count <= 1) return;
const difference = adding ? targets[index]! - count : count - targets[index]!;
if (difference > greatest) { greatest = difference; candidate = index; }
});
counts[candidate]! += adding ? 1 : -1;
current += adding ? 1 : -1;
}
return counts;
}
+4
View File
@@ -4,3 +4,7 @@ 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';
export { createOrgChartExampleDataset } from './examples.js';
export type { OrgChartExampleScope, OrgChartExampleEmployeeCount, OrgChartExampleSelection } from './examples.js';
export { createOrgChartMoveValidator, validateOrgChartMove, applyOrgChartMove } from './moves.js';
export type { OrgChartMoveRequest, OrgChartMoveFailureCode, OrgChartMoveFailure, OrgChartMoveValidation, OrgChartMoveResult } from './moves.js';
+214
View File
@@ -0,0 +1,214 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyOrgChartMove, createOrgChartMoveValidator, validateOrgChartMove, type OrgChartMoveFailureCode, type OrgChartMoveRequest } from './moves.js';
import { calculateSummary } from './summary.js';
import { normalizeOrgChartDataset } from './normalize.js';
import { snapshotOrgChartDataset } from './snapshot.js';
import { validateOrgChartDataset } from './validate.js';
import type { Employee, OrgChartDataset } from './types.js';
function member(id: string, organizationId: string, affiliationId = 'company-a'): Employee {
return { id, organizationId, affiliationId, name: `가상 ${id}`, position: '선임', jobTitle: null, profileImage: './profiles/virtual.png', workStatus: 'active' };
}
function fixture(): OrgChartDataset {
return {
schemaVersion: '0.1-draft',
organizations: [
{ id: 'a-root', companyId: 'company-a', parentId: null, name: '가상 A', type: 'root', leaderId: 'root-leader', displayOrder: 0 },
{ id: 'a-division', parentId: 'a-root', name: '가상 본부', type: 'division', leaderId: null, displayOrder: 4 },
{ id: 'a-team', parentId: 'a-division', name: '가상 팀', type: 'team', leaderId: 'team-leader', displayOrder: 8 },
{ id: 'a-cell', parentId: 'a-team', name: '가상 셀', type: 'cell', leaderId: null, displayOrder: 7 },
{ id: 'a-other', parentId: 'a-root', name: '가상 다른 본부', type: 'division', leaderId: null, displayOrder: 2 },
{ id: 'b-root', companyId: 'company-b', parentId: null, name: '가상 B', type: 'root', leaderId: null, displayOrder: 1 },
{ id: 'b-team', parentId: 'b-root', name: '가상 B 팀', type: 'team', leaderId: null, displayOrder: 1 },
],
employees: [member('root-leader', 'a-root'), member('team-leader', 'a-team'), member('staff', 'a-team', 'company-b'), member('cell-staff', 'a-cell'), member('b-staff', 'b-team', 'company-b')],
affiliations: [{ id: 'company-a', name: '가상 A 회사' }, { id: 'company-b', name: '가상 B 회사' }],
relations: [{ id: 'support', sourceOrganizationId: 'a-team', targetOrganizationId: 'a-other', type: 'support' }],
presentation: {
title: '가상 조직도', description: null, hubText: null,
groups: { showEmployees: true, showSummary: true, showDispatched: true, showOnLeave: true, showAffiliations: true },
organizationOrder: ['a-other', 'a-team'],
},
metadata: { asOfDate: '2026-10-13', dataVersion: 'move-test' },
};
}
function request(kind: OrgChartMoveRequest['kind'], sourceId: string, targetOrganizationId: string): OrgChartMoveRequest {
return { kind, sourceId, targetOrganizationId };
}
function expectRejected(data: OrgChartDataset, move: OrgChartMoveRequest, code: OrgChartMoveFailureCode) {
const before = JSON.stringify(data);
const policy = validateOrgChartMove(data, move);
assert.equal(policy.allowed, false);
if (!policy.allowed) { assert.equal(policy.code, code); assert.ok(policy.message.length > 0); }
const result = applyOrgChartMove(data, move);
assert.equal(result.moved, false);
if (!result.moved) assert.equal(result.code, code);
assert.equal(JSON.stringify(data), before, 'rejected movement retains every input record');
}
test('employee move changes organizationId alone, retains affiliation and IDs, updates counts, and owns frozen state', () => {
const original = snapshotOrgChartDataset(fixture());
const before = JSON.stringify(original);
const move = request('employee', 'staff', 'a-other');
const result = applyOrgChartMove(original, move);
assert.equal(result.moved, true);
if (!result.moved) return;
assert.equal(result.previousOrganizationId, 'a-team');
assert.deepEqual(result.request, move);
assert.deepEqual(result.data.employees.find(employee => employee.id === 'staff'), { ...original.employees.find(employee => employee.id === 'staff')!, organizationId: 'a-other' });
assert.deepEqual(result.data, { ...original, employees: original.employees.map(employee => employee.id === 'staff' ? { ...employee, organizationId: 'a-other' } : employee) });
assert.equal(JSON.stringify(original), before);
assert.ok(Object.isFrozen(result.data) && Object.isFrozen(result.data.employees) && Object.isFrozen(result.data.employees[0]) && Object.isFrozen(result.request));
assert.notEqual(result.data.employees[0], original.employees[0]);
const normalized = normalizeOrgChartDataset(result.data);
assert.equal(normalized.employeesByOrganization.get('a-team')!.some(employee => employee.id === 'staff'), false);
assert.equal(normalized.employeesByOrganization.get('a-other')!.filter(employee => employee.id === 'staff').length, 1);
const beforeCounts = calculateSummary(original).raw;
const afterCounts = calculateSummary(result.data).raw;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-team']!.direct, beforeCounts.byOrganization['a-team']!.direct - 1);
assert.equal(afterCounts.byOrganization['a-other']!.inclusive, beforeCounts.byOrganization['a-other']!.inclusive + 1);
assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
test('group move changes parentId alone, preserving subtree, leaders, sorting, company, relations and employee total', () => {
const original = snapshotOrgChartDataset(fixture());
const before = JSON.stringify(original);
const result = applyOrgChartMove(original, request('organization', 'a-team', 'a-other'));
assert.equal(result.moved, true);
if (!result.moved) return;
assert.equal(result.previousOrganizationId, 'a-division');
assert.deepEqual(result.data, { ...original, organizations: original.organizations.map(org => org.id === 'a-team' ? { ...org, parentId: 'a-other' } : org) });
assert.equal(JSON.stringify(original), before);
const normalized = normalizeOrgChartDataset(result.data);
assert.equal(normalized.organizationsById.get('a-cell')!.parentId, 'a-team');
assert.equal(normalized.organizationsById.get('a-team')!.leaderId, 'team-leader');
assert.equal(normalized.organizationsById.get('a-team')!.displayOrder, 8);
assert.equal(new Set(result.data.organizations.map(org => org.id)).size, original.organizations.length);
const beforeCounts = calculateSummary(original).raw;
const afterCounts = calculateSummary(result.data).raw;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-team']!.inclusive, beforeCounts.byOrganization['a-team']!.inclusive);
assert.equal(afterCounts.byOrganization['a-division']!.inclusive, 0);
assert.equal(afterCounts.byOrganization['a-other']!.inclusive, beforeCounts.byOrganization['a-team']!.inclusive);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
test('employee policy rejects leaders, absent IDs and existing membership', () => {
const data = fixture();
expectRejected(data, request('employee', 'team-leader', 'a-other'), 'LEADER_PROTECTED');
expectRejected(data, request('employee', 'missing', 'a-other'), 'SOURCE_NOT_FOUND');
expectRejected(data, request('employee', 'staff', 'missing'), 'TARGET_NOT_FOUND');
expectRejected(data, request('employee', 'staff', 'a-team'), 'SAME_ORGANIZATION');
});
test('group policy rejects roots, self, descendants, existing parent and absent IDs', () => {
const data = fixture();
expectRejected(data, request('organization', 'a-root', 'a-other'), 'ROOT_PROTECTED');
expectRejected(data, request('organization', 'a-team', 'a-team'), 'SELF_TARGET');
expectRejected(data, request('organization', 'a-team', 'a-cell'), 'DESCENDANT_TARGET');
expectRejected(data, request('organization', 'a-team', 'a-division'), 'SAME_ORGANIZATION');
expectRejected(data, request('organization', 'missing', 'a-other'), 'SOURCE_NOT_FOUND');
expectRejected(data, request('organization', 'a-team', 'missing'), 'TARGET_NOT_FOUND');
});
test('cross-company employee move updates existing organization and affiliation references, company totals and immutable state', () => {
const data = fixture(); data.employees[2]!.affiliationId = 'company-a';
const original = snapshotOrgChartDataset(data), before = JSON.stringify(original);
const move = request('employee', 'staff', 'b-team');
assert.deepEqual(validateOrgChartMove(original, move), { allowed: true, previousOrganizationId: 'a-team', previousCompanyId: 'company-a', companyId: 'company-b' });
const result = applyOrgChartMove(original, move);
assert.equal(result.moved, true);
if (!result.moved) return;
assert.deepEqual(result.data, { ...original, employees: original.employees.map(employee => employee.id === 'staff'
? { ...employee, organizationId: 'b-team', affiliationId: 'company-b' } : employee) });
const beforeCounts = calculateSummary(original).raw, afterCounts = calculateSummary(result.data).raw;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-root']!.inclusive, beforeCounts.byOrganization['a-root']!.inclusive - 1);
assert.equal(afterCounts.byOrganization['b-root']!.inclusive, beforeCounts.byOrganization['b-root']!.inclusive + 1);
assert.equal(afterCounts.byAffiliation['company-a'], beforeCounts.byAffiliation['company-a']! - 1);
assert.equal(afterCounts.byAffiliation['company-b'], beforeCounts.byAffiliation['company-b']! + 1);
assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length);
assert.ok(Object.isFrozen(result.data.employees[2]));
assert.equal(JSON.stringify(original), before);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
test('cross-company group move transfers all descendant companies and employee affiliations while retaining subtree and leaders', () => {
const original = snapshotOrgChartDataset(fixture()), before = JSON.stringify(original);
const result = applyOrgChartMove(original, request('organization', 'a-team', 'b-team'));
assert.equal(result.moved, true);
if (!result.moved) return;
const subtree = new Set(['a-team', 'a-cell']);
assert.deepEqual(result.data, { ...original,
organizations: original.organizations.map(org => org.id === 'a-team' ? { ...org, parentId: 'b-team', companyId: 'company-b' }
: subtree.has(org.id) ? { ...org, companyId: 'company-b' } : org),
employees: original.employees.map(employee => subtree.has(employee.organizationId) ? { ...employee, affiliationId: 'company-b' } : employee),
});
assert.equal(result.data.organizations.find(org => org.id === 'a-cell')!.parentId, 'a-team');
assert.equal(result.data.organizations.find(org => org.id === 'a-team')!.leaderId, 'team-leader');
assert.equal(result.data.employees.find(employee => employee.id === 'team-leader')!.organizationId, 'a-team');
const beforeCounts = calculateSummary(original).raw, afterCounts = calculateSummary(result.data).raw;
const transferred = beforeCounts.byOrganization['a-team']!.inclusive;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-root']!.inclusive, beforeCounts.byOrganization['a-root']!.inclusive - transferred);
assert.equal(afterCounts.byOrganization['b-root']!.inclusive, beforeCounts.byOrganization['b-root']!.inclusive + transferred);
assert.equal(afterCounts.byAffiliation['company-b'], 4);
assert.equal(new Set(result.data.organizations.map(org => org.id)).size, original.organizations.length);
assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length);
assert.equal(JSON.stringify(original), before);
assert.equal(validateOrgChartDataset(result.data).valid, true);
assert.equal(validateOrgChartMove(result.data, request('organization', 'a-cell', 'a-other')).allowed, true, 'transferred subtree can subsequently move back');
});
test('cross-company moves reject unknown target affiliation rather than inventing a new company record', () => {
const data = fixture(); data.organizations[5]!.companyId = 'unregistered-company';
assert.equal(validateOrgChartDataset(data).valid, true);
expectRejected(data, request('employee', 'staff', 'b-team'), 'TARGET_AFFILIATION_MISSING');
expectRejected(data, request('organization', 'a-team', 'b-team'), 'TARGET_AFFILIATION_MISSING');
});
test('policy rejects malformed request, duplicate IDs, invalid source relationships and company hierarchy contradictions', () => {
expectRejected(fixture(), { kind: 'unknown', sourceId: 'staff', targetOrganizationId: 'a-other' } as unknown as OrgChartMoveRequest, 'INVALID_REQUEST');
const duplicate = fixture(); duplicate.employees.push({ ...duplicate.employees[2]! });
expectRejected(duplicate, request('employee', 'staff', 'a-other'), 'INVALID_DATA');
const cycle = fixture(); cycle.organizations[1]!.parentId = 'a-cell';
expectRejected(cycle, request('employee', 'staff', 'a-other'), 'INVALID_DATA');
const invalidEmployee = fixture(); invalidEmployee.employees[2]!.organizationId = 'missing';
expectRejected(invalidEmployee, request('employee', 'staff', 'a-other'), 'INVALID_DATA');
const conflict = fixture(); conflict.organizations[2]!.companyId = 'company-b';
expectRejected(conflict, request('organization', 'a-team', 'a-other'), 'INVALID_DATA');
});
test('legacy roots derive company through their leader but unresolved company identity prevents moves', () => {
const legacy = fixture(); delete legacy.organizations[0]!.companyId;
assert.equal(validateOrgChartMove(legacy, request('employee', 'staff', 'a-other')).allowed, true);
legacy.organizations[0]!.leaderId = null;
expectRejected(legacy, request('employee', 'staff', 'a-other'), 'COMPANY_UNKNOWN');
});
test('drop-target validator snapshots once and retains its policy if a caller later mutates the dataset', () => {
const data = fixture();
const policy = createOrgChartMoveValidator(data);
data.employees[2]!.organizationId = 'b-team';
assert.deepEqual(policy(request('employee', 'staff', 'a-other')), { allowed: true, previousOrganizationId: 'a-team', previousCompanyId: 'company-a', companyId: 'company-a' });
assert.deepEqual(validateOrgChartMove(data, request('employee', 'staff', 'a-other')), { allowed: true, previousOrganizationId: 'b-team', previousCompanyId: 'company-b', companyId: 'company-a' });
});
test('opaque IDs remain safe move keys without duplicate or synthetic records', () => {
const data = fixture();
data.organizations[4]!.id = '__proto__';
data.relations![0]!.targetOrganizationId = '__proto__';
data.presentation!.organizationOrder[0] = '__proto__';
const result = applyOrgChartMove(data, request('employee', 'staff', '__proto__'));
assert.equal(result.moved, true);
if (!result.moved) return;
assert.equal(result.data.employees.find(employee => employee.id === 'staff')!.organizationId, '__proto__');
assert.equal(result.data.employees.length, data.employees.length);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
+150
View File
@@ -0,0 +1,150 @@
import { normalizeOrgChartDataset } from './normalize.js';
import { validateOrgChartDataset } from './validate.js';
import type { OrgChartDataset } from './types.js';
/** Source IDs retain their original collection identity after a browser-state move. */
export interface OrgChartMoveRequest {
kind: 'employee' | 'organization';
sourceId: string;
targetOrganizationId: string;
}
export type OrgChartMoveFailureCode =
| 'EDITING_DISABLED'
| 'INVALID_DATA' | 'INVALID_REQUEST' | 'SOURCE_NOT_FOUND' | 'TARGET_NOT_FOUND'
| 'COMPANY_UNKNOWN' | 'TARGET_AFFILIATION_MISSING' | 'LEADER_PROTECTED' | 'ROOT_PROTECTED'
| 'SELF_TARGET' | 'DESCENDANT_TARGET' | 'SAME_ORGANIZATION';
export interface OrgChartMoveFailure {
code: OrgChartMoveFailureCode;
message: string;
}
export type OrgChartMoveValidation =
| { allowed: true; previousOrganizationId: string; previousCompanyId: string; companyId: string }
| ({ allowed: false } & OrgChartMoveFailure);
export type OrgChartMoveResult =
| { moved: true; data: OrgChartDataset; request: OrgChartMoveRequest; previousOrganizationId: string }
| ({ moved: false } & OrgChartMoveFailure);
const messages: Record<OrgChartMoveFailureCode, string> = {
EDITING_DISABLED: 'Drag & Drop이 OFF입니다. 조직 이동을 사용하려면 ON으로 설정하세요.',
INVALID_DATA: '조직 데이터의 관계가 유효하지 않아 이동할 수 없습니다.',
INVALID_REQUEST: '이동 요청이 올바르지 않습니다.',
SOURCE_NOT_FOUND: '이동할 직원 또는 조직이 존재하지 않습니다.',
TARGET_NOT_FOUND: '이동할 대상 조직이 존재하지 않습니다.',
COMPANY_UNKNOWN: '회사 소속을 확인할 수 없어 이동할 수 없습니다.',
TARGET_AFFILIATION_MISSING: '대상 회사의 소속 정보가 없어 회사 간 이동을 완료할 수 없습니다.',
LEADER_PROTECTED: '조직장은 조직장 지정 정책을 먼저 변경해야 이동할 수 있습니다.',
ROOT_PROTECTED: '회사 최상위 조직은 이동할 수 없습니다.',
SELF_TARGET: '조직을 자기 자신에게 이동할 수 없습니다.',
DESCENDANT_TARGET: '하위 조직으로 이동하면 순환 관계가 생겨 이동할 수 없습니다.',
SAME_ORGANIZATION: '이미 해당 조직에 속해 있습니다.',
};
function deny(code: OrgChartMoveFailureCode): OrgChartMoveValidation {
return { allowed: false, code, message: messages[code] };
}
/** Validate once for all highlighted drop targets. The closure owns an immutable
* dataset snapshot, so later consumer mutations cannot change a drag's policy.
* Company is the organization tree's identity, not an employee's affiliation.
* Legacy roots may establish it through their leader's affiliation; no arbitrary
* fallback company is invented when ownership cannot be established.
*/
export function createOrgChartMoveValidator(data: OrgChartDataset): (request: OrgChartMoveRequest) => OrgChartMoveValidation {
const validation = validateOrgChartDataset(data);
if (!validation.valid) return () => deny('INVALID_DATA');
const owned = validation.data;
const normalized = normalizeOrgChartDataset(owned);
const employees = new Map(owned.employees.map(employee => [employee.id, employee]));
const affiliations = new Set(owned.affiliations.map(affiliation => affiliation.id));
const leaders = new Set(owned.organizations.flatMap(org => org.leaderId === null ? [] : [org.leaderId]));
const companies = new Map<string, string | undefined>();
const pending = normalized.roots.map(node => ({ node, inherited: undefined as string | undefined }));
while (pending.length) {
const { node, inherited } = pending.pop()!;
const org = node.organization;
const company = org.companyId ?? inherited ?? (org.parentId === null && org.leaderId ? employees.get(org.leaderId)?.affiliationId : undefined);
if (inherited && company !== inherited) return () => deny('INVALID_DATA');
companies.set(org.id, company);
pending.push(...node.children.map(child => ({ node: child, inherited: company })));
}
return request => {
if (!request || !['employee', 'organization'].includes(request.kind)
|| typeof request.sourceId !== 'string' || !request.sourceId
|| typeof request.targetOrganizationId !== 'string' || !request.targetOrganizationId) return deny('INVALID_REQUEST');
const source = request.kind === 'employee' ? employees.get(request.sourceId) : normalized.organizationsById.get(request.sourceId);
if (!source) return deny('SOURCE_NOT_FOUND');
const target = normalized.organizationsById.get(request.targetOrganizationId);
if (!target) return deny('TARGET_NOT_FOUND');
const sourceOrganization = request.kind === 'employee'
? normalized.organizationsById.get(employees.get(request.sourceId)!.organizationId)!
: normalized.organizationsById.get(request.sourceId)!;
const previousOrganizationId = request.kind === 'employee' ? sourceOrganization.id : sourceOrganization.parentId;
if (request.kind === 'employee') {
if (sourceOrganization.id === target.id) return deny('SAME_ORGANIZATION');
if (leaders.has(request.sourceId)) return deny('LEADER_PROTECTED');
} else {
if (sourceOrganization.id === target.id) return deny('SELF_TARGET');
if (sourceOrganization.parentId === null || sourceOrganization.type === 'root') return deny('ROOT_PROTECTED');
if (sourceOrganization.parentId === target.id) return deny('SAME_ORGANIZATION');
let ancestor: typeof target | undefined = target;
while (ancestor) {
if (ancestor.id === sourceOrganization.id) return deny('DESCENDANT_TARGET');
ancestor = ancestor.parentId === null ? undefined : normalized.organizationsById.get(ancestor.parentId);
}
}
const sourceCompany = companies.get(sourceOrganization.id);
const targetCompany = companies.get(target.id);
if (!sourceCompany || !targetCompany) return deny('COMPANY_UNKNOWN');
if (sourceCompany !== targetCompany && !affiliations.has(targetCompany)) return deny('TARGET_AFFILIATION_MISSING');
return { allowed: true, previousOrganizationId: previousOrganizationId!, previousCompanyId: sourceCompany, companyId: targetCompany };
};
}
/** Pure policy check for a single keyboard or pointer move. */
export function validateOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveValidation {
return createOrgChartMoveValidator(data)(request);
}
/** Create a new validated, deeply frozen browser-state dataset. Same-company
* moves change membership alone. Cross-company employee moves also change the
* existing affiliationId; group moves retarget companyId for its whole subtree
* and affiliationId for every subtree employee. No new contract fields are used.
* IDs, descendants, leaders, personal data and display ordering are retained.
* Failed moves return no replacement data and never alter the input.
*/
export function applyOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveResult {
const policy = validateOrgChartMove(data, request);
if (!policy.allowed) return { moved: false, code: policy.code, message: policy.message };
const crossCompany = policy.previousCompanyId !== policy.companyId;
let candidate: OrgChartDataset;
if (request.kind === 'employee') {
candidate = { ...data, employees: data.employees.map(employee => employee.id === request.sourceId
? { ...employee, organizationId: request.targetOrganizationId, ...(crossCompany ? { affiliationId: policy.companyId } : {}) } : employee) };
} else {
const subtree = new Set<string>([request.sourceId]);
if (crossCompany) {
const children = new Map<string, string[]>();
for (const org of data.organizations) {
if (org.parentId === null) continue;
const siblings = children.get(org.parentId) ?? [];
siblings.push(org.id); children.set(org.parentId, siblings);
}
const pending = [request.sourceId];
while (pending.length) for (const id of children.get(pending.pop()!) ?? []) { subtree.add(id); pending.push(id); }
}
candidate = {
...data,
organizations: data.organizations.map(org => org.id === request.sourceId
? { ...org, parentId: request.targetOrganizationId, ...(crossCompany ? { companyId: policy.companyId } : {}) }
: crossCompany && subtree.has(org.id) ? { ...org, companyId: policy.companyId } : org),
employees: crossCompany ? data.employees.map(employee => subtree.has(employee.organizationId)
? { ...employee, affiliationId: policy.companyId } : employee) : data.employees,
};
}
const validation = validateOrgChartDataset(candidate);
if (!validation.valid) return { moved: false, code: 'INVALID_DATA', message: messages.INVALID_DATA };
return { moved: true, data: validation.data, request: Object.freeze({ ...request }), previousOrganizationId: policy.previousOrganizationId };
}