feat(org-chart): finalize chart features and AI generation rules
This commit is contained in:
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/);
|
||||
});
|
||||
@@ -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,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';
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user