Files
HMDesign/packages/org-chart/src/data/moves.test.ts
T

215 lines
15 KiB
TypeScript

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);
});