134 lines
7.5 KiB
TypeScript
134 lines
7.5 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { selectAffiliationSummary, selectMajorOrganizationTotals, selectOrganizationPositionCounts, selectStatusGroups } from './content-selectors.js';
|
|
import { calculateSummary } from './summary.js';
|
|
import type { OrgChartDataset } from './types.js';
|
|
|
|
function fixture(): OrgChartDataset {
|
|
return {
|
|
schemaVersion: '0.1-draft',
|
|
organizations: [
|
|
{ id: 'root', parentId: null, name: '가상 본부', type: 'root', leaderId: 'leader', displayOrder: 0 },
|
|
{ id: 'team', parentId: 'root', name: '가상 팀', type: 'team', leaderId: null, displayOrder: 1 },
|
|
{ id: 'empty', parentId: 'root', name: '빈 가상 팀', type: 'team', leaderId: null, displayOrder: 2 },
|
|
],
|
|
employees: [
|
|
{ id: 'leader', organizationId: 'root', affiliationId: 'first', name: '가상 가', position: '대표', jobTitle: '본부장', profileImage: null, workStatus: 'active' },
|
|
{ id: 'sent', organizationId: 'team', affiliationId: 'second', name: '가상 나', position: '책임', jobTitle: null, profileImage: null, workStatus: 'dispatched' },
|
|
{ id: 'leave', organizationId: 'team', affiliationId: 'first', name: '가상 다', position: '책임', jobTitle: null, profileImage: null, workStatus: 'onLeave' },
|
|
],
|
|
affiliations: [{ id: 'first', name: '가상 관계사 가' }, { id: 'second', name: '가상 관계사 나' }, { id: 'zero', name: '가상 관계사 다' }],
|
|
metadata: { asOfDate: '2026-10-12', dataVersion: 'selector-test' },
|
|
};
|
|
}
|
|
|
|
test('position matrix uses direct primary membership, includes leaders and all work statuses', () => {
|
|
const data = fixture();
|
|
const matrix = selectOrganizationPositionCounts(data);
|
|
const summary = calculateSummary(data);
|
|
assert.deepEqual([...matrix.get('root')!], [['대표', 1]]);
|
|
assert.deepEqual([...matrix.get('team')!], [['책임', 2]]);
|
|
assert.equal(matrix.get('empty')!.size, 0);
|
|
assert.equal(matrix.get('root')!.get('책임'), undefined);
|
|
assert.equal(summary.raw.byOrganization.root!.inclusive, 3);
|
|
for (const organization of data.organizations) {
|
|
assert.equal([...matrix.get(organization.id)!.values()].reduce((sum, count) => sum + count, 0), summary.raw.byOrganization[organization.id]!.direct);
|
|
}
|
|
for (const [position, count] of Object.entries(summary.raw.byPosition)) {
|
|
assert.equal([...matrix.values()].reduce((sum, row) => sum + (row.get(position) ?? 0), 0), count);
|
|
}
|
|
assert.equal([...matrix.values()].flatMap(row => [...row.values()]).reduce((sum, count) => sum + count, 0), summary.raw.total);
|
|
assert.equal(summary.aggregation.status, 'unavailable');
|
|
});
|
|
|
|
test('duplicate IDs cannot inflate matrix or status groups and original Employee references survive', () => {
|
|
const data = fixture();
|
|
data.employees.push({ ...data.employees[1]!, organizationId: 'root', position: '대표' });
|
|
const summary = calculateSummary(data);
|
|
const matrix = selectOrganizationPositionCounts(data);
|
|
assert.equal(matrix.get('team')!.get('책임'), 2);
|
|
assert.equal(matrix.get('root')!.get('대표'), 1);
|
|
const groups = selectStatusGroups(data, summary);
|
|
assert.equal(groups[0]!.employees.length, 1);
|
|
assert.strictEqual(groups[0]!.employees[0], data.employees[1]);
|
|
assert.strictEqual(groups[1]!.employees[0], data.employees[2]);
|
|
assert.equal(summary.raw.total, 3);
|
|
});
|
|
|
|
test('opaque prototype-like organization IDs and position strings remain ordinary matrix keys', () => {
|
|
const data = fixture();
|
|
data.organizations[1]!.id = '__proto__';
|
|
data.employees[1]!.organizationId = '__proto__';
|
|
data.employees[2]!.organizationId = '__proto__';
|
|
data.employees[1]!.position = 'constructor';
|
|
data.employees[2]!.position = '__proto__';
|
|
const row = selectOrganizationPositionCounts(data).get('__proto__')!;
|
|
assert.equal(row.get('constructor'), 1);
|
|
assert.equal(row.get('__proto__'), 1);
|
|
});
|
|
|
|
test('affiliation summary preserves declared order, zero entries and original affiliation objects', () => {
|
|
const data = fixture();
|
|
const summary = calculateSummary(data);
|
|
const rows = selectAffiliationSummary(data, summary);
|
|
assert.deepEqual(rows.map(row => [row.affiliation.id, row.count]), [['first', 2], ['second', 1], ['zero', 0]]);
|
|
assert.strictEqual(rows[2]!.affiliation, data.affiliations[2]);
|
|
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), summary.raw.total);
|
|
});
|
|
|
|
test('status flags independently hide groups without changing Raw Count or affiliation totals', () => {
|
|
const data = fixture();
|
|
const summary = calculateSummary(data);
|
|
data.presentation = {
|
|
title: '가상 조직도', description: null, hubText: null, organizationOrder: [],
|
|
groups: { showEmployees: false, showSummary: false, showAffiliations: false, showDispatched: false, showOnLeave: true },
|
|
};
|
|
assert.deepEqual(selectStatusGroups(data, summary).map(group => group.status), ['onLeave']);
|
|
data.presentation.groups.showDispatched = true;
|
|
data.presentation.groups.showOnLeave = false;
|
|
assert.deepEqual(selectStatusGroups(data, summary).map(group => group.status), ['dispatched']);
|
|
data.presentation.groups.showDispatched = false;
|
|
assert.deepEqual(selectStatusGroups(data, summary), []);
|
|
assert.deepEqual(calculateSummary(data), summary);
|
|
assert.equal(selectAffiliationSummary(data, summary).length, 3);
|
|
});
|
|
|
|
test('zero employees retains all organization/affiliation rows and both empty status groups', () => {
|
|
const data = fixture();
|
|
data.employees = [];
|
|
data.organizations[0]!.leaderId = null;
|
|
const summary = calculateSummary(data);
|
|
assert.equal(selectOrganizationPositionCounts(data).size, 3);
|
|
assert.ok([...selectOrganizationPositionCounts(data).values()].every(row => row.size === 0));
|
|
assert.ok(selectAffiliationSummary(data, summary).every(row => row.count === 0));
|
|
assert.deepEqual(selectStatusGroups(data, summary).map(group => [group.status, group.count, group.employees.length]), [['dispatched', 0, 0], ['onLeave', 0, 0]]);
|
|
});
|
|
|
|
test('new JSON snapshot updates matrix, affiliations and status groups without a cached count', () => {
|
|
const data = fixture();
|
|
const before = calculateSummary(data);
|
|
data.employees[1]!.organizationId = 'root';
|
|
data.employees[1]!.position = '수석';
|
|
data.employees[1]!.affiliationId = 'zero';
|
|
data.employees[1]!.workStatus = 'active';
|
|
const after = calculateSummary(data);
|
|
const matrix = selectOrganizationPositionCounts(data);
|
|
assert.equal(matrix.get('root')!.get('수석'), 1);
|
|
assert.equal(matrix.get('team')!.get('책임'), 1);
|
|
assert.deepEqual(selectAffiliationSummary(data, after).map(row => row.count), [2, 0, 1]);
|
|
assert.equal(selectStatusGroups(data, after)[0]!.employees.length, 0);
|
|
assert.equal(before.raw.byStatus.dispatched, 1);
|
|
assert.equal(after.raw.total, before.raw.total);
|
|
});
|
|
|
|
test('major organization totals: root direct members then inclusive root children, summing to the raw total', () => {
|
|
const data = fixture();
|
|
data.organizations.push({ id: 'cell', parentId: 'team', name: '가상 셀', type: 'cell', leaderId: null, displayOrder: 3 });
|
|
data.employees.push({ id: 'member', organizationId: 'cell', affiliationId: 'zero', name: '가상 라', position: '선임', jobTitle: null, profileImage: null, workStatus: 'active' });
|
|
const summary = calculateSummary(data);
|
|
const rows = selectMajorOrganizationTotals(data, summary);
|
|
assert.deepEqual(rows.map(row => [row.organization.id, row.scope, row.count]), [['root', 'direct', 1], ['team', 'inclusive', 3], ['empty', 'inclusive', 0]]);
|
|
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), summary.raw.total);
|
|
assert.equal(rows.some(row => row.organization.id === 'cell'), false);
|
|
});
|