import { describe, expect, it } from "vitest"; import { buildOrgSelectionOptions, buildUsersMap, clampScale, filterSystemGlobalTenants, getMemberGridMetrics, getOrgNodeHeaderFill, getSemanticZoomMode, layoutForest, resolveOrgChartFamilyRoot, type OrgNode, } from "./OrgChartPage"; function orgNode(id: string, children: OrgNode[] = [], level = 0): OrgNode { return { id, name: id, level, members: [], children, totalCount: 0, totalMemberIds: new Set(), companyCode: id, type: level === 0 ? "COMPANY" : "USER_GROUP", }; } function member(id: string) { return { id, email: `${id}@example.com`, name: id, role: "user", status: "active", companyCode: "root", grade: "사원", createdAt: "2026-05-11T00:00:00.000Z", updatedAt: "2026-05-11T00:00:00.000Z", }; } function tenantNode( id: string, type: string, name: string, slug: string, children = [], ) { return { id, type, name, slug, children, description: "", status: "active", memberCount: 0, recursiveMemberCount: 0, createdAt: "2026-05-11T00:00:00.000Z", updatedAt: "2026-05-11T00:00:00.000Z", }; } function getNodeBoundsAspectRatio( nodes: ReturnType["nodes"], ) { const minX = Math.min(...nodes.map((node) => node.x)); const maxX = Math.max(...nodes.map((node) => node.x + node.width)); const minY = Math.min(...nodes.map((node) => node.y)); const maxY = Math.max(...nodes.map((node) => node.y + node.height)); return (maxX - minX) / (maxY - minY); } describe("org chart layout", () => { it("keeps small sibling groups horizontal in automatic mode", () => { const children = Array.from({ length: 4 }, (_, index) => orgNode(`child-${index + 1}`, [], 1), ); const layout = layoutForest([orgNode("root", children)], new Set()); const childNodes = layout.nodes.filter((node) => node.node.id.startsWith("child-"), ); expect(new Set(childNodes.map((node) => node.y)).size).toBe(1); }); it("uses member columns in node bounds when the rendered node aspect ratio needs them", () => { const compactMembers = Array.from({ length: 10 }, (_, index) => member(`member-${index + 1}`), ); const node = { ...orgNode("root"), members: compactMembers, totalCount: compactMembers.length, totalMemberIds: new Set(compactMembers.map((item) => item.id)), }; const layout = layoutForest([node], new Set()); const rootNode = layout.nodes.find((item) => item.node.id === "root"); expect(rootNode).toBeDefined(); expect(rootNode?.width).toBeGreaterThan(240); expect(rootNode?.height).toBeLessThan(42 + 24 + 10 * 24); expect(layout.width).toBeGreaterThan((rootNode?.width ?? 0) + 72 * 2 - 1); }); it("sizes member cards from an eight-character baseline and expands for long display names", () => { const shortMembers = Array.from({ length: 6 }, (_, index) => ({ ...member(`short-${index + 1}`), name: `홍길${index + 1}`, grade: "책임", })); const longMembers = shortMembers.map((item, index) => ({ ...item, id: `long-${index + 1}`, name: `매우긴사용자이름${index + 1}`, })); const shortLayout = layoutForest( [ { ...orgNode("short"), members: shortMembers, totalCount: shortMembers.length, totalMemberIds: new Set(shortMembers.map((item) => item.id)), }, ], new Set(), ); const longLayout = layoutForest( [ { ...orgNode("long"), members: longMembers, totalCount: longMembers.length, totalMemberIds: new Set(longMembers.map((item) => item.id)), }, ], new Set(), ); const shortNode = shortLayout.nodes.find( (item) => item.node.id === "short", ); const longNode = longLayout.nodes.find((item) => item.node.id === "long"); expect(shortNode?.width).toBeLessThan(320); expect(longNode?.width).toBeGreaterThan(shortNode?.width ?? 0); }); it("uses compact member columns when another column improves the rendered ratio", () => { const tenMembers = Array.from({ length: 10 }, (_, index) => member(`member-${index + 1}`), ); const sixMembers = tenMembers.slice(0, 6); const sixLayout = layoutForest( [ { ...orgNode("six"), members: sixMembers, totalCount: sixMembers.length, totalMemberIds: new Set(sixMembers.map((item) => item.id)), }, ], new Set(), ); const tenLayout = layoutForest( [ { ...orgNode("ten"), members: tenMembers, totalCount: tenMembers.length, totalMemberIds: new Set(tenMembers.map((item) => item.id)), }, ], new Set(), ); const sixNode = sixLayout.nodes.find((item) => item.node.id === "six"); const tenNode = tenLayout.nodes.find((item) => item.node.id === "ten"); expect(sixNode?.width).toBeGreaterThan(240); expect(tenNode?.width).toBe(sixNode?.width); expect(sixNode?.height).toBeLessThan(42 + 24 + 6 * 24); expect(tenNode?.height).toBeLessThan(42 + 24 + 10 * 24); }); it("chooses member columns from the rendered node aspect ratio instead of fixed five-member buckets", () => { expect(getMemberGridMetrics(6)).toEqual({ columnCount: 2, rowCount: 3 }); expect(getMemberGridMetrics(10)).toEqual({ columnCount: 2, rowCount: 5 }); expect(getMemberGridMetrics(25)).toEqual({ columnCount: 4, rowCount: 7 }); }); it("sorts members by normalized rank inside the same organization", () => { const members = [ { ...member("staff"), name: "사원", grade: "사원" }, { ...member("principal"), name: "수석", grade: "수석연구원" }, { ...member("director"), name: "전무", grade: "전무이사" }, { ...member("lead"), name: "책임", grade: "책임" }, ]; const layout = layoutForest( [ { ...orgNode("root"), members, totalCount: members.length, totalMemberIds: new Set(members.map((item) => item.id)), }, ], new Set(), ); const rootNode = layout.nodes.find((item) => item.node.id === "root"); expect(rootNode?.members.map((item) => item.id)).toEqual([ "director", "principal", "lead", "staff", ]); }); it("uses multi-column layout by default when sibling width crosses the threshold", () => { const children = Array.from({ length: 13 }, (_, index) => orgNode(`child-${index + 1}`, [], 1), ); const layout = layoutForest([orgNode("root", children)], new Set()); const childNodes = layout.nodes.filter((node) => node.node.id.startsWith("child-"), ); const uniqueChildRows = new Set(childNodes.map((node) => node.y)); const childSpan = Math.max(...childNodes.map((node) => node.x + node.width)) - Math.min(...childNodes.map((node) => node.x)); const aspectRatio = getNodeBoundsAspectRatio(layout.nodes); expect(childNodes).toHaveLength(13); expect(uniqueChildRows.size).toBeGreaterThan(1); expect(aspectRatio).toBeGreaterThanOrEqual(1.41); expect(aspectRatio).toBeLessThanOrEqual(1.61); expect(childSpan).toBeLessThan(13 * 240 + 12 * 80); expect( layout.edges.filter((edge) => edge.key.startsWith("root->")), ).toHaveLength(13); expect( layout.edges.filter( (edge) => edge.key.startsWith("root->") && edge.visibleByDefault, ), ).toHaveLength(new Set(childNodes.map((node) => node.x)).size); }); it("tunes column and row gaps after column selection to keep auto layout near the target aspect ratio", () => { const children = Array.from({ length: 5 }, (_, index) => orgNode(`child-${index + 1}`, [], 1), ); const layout = layoutForest([orgNode("root", children)], new Set()); const childNodes = layout.nodes.filter((node) => node.node.id.startsWith("child-"), ); const aspectRatio = getNodeBoundsAspectRatio(layout.nodes); expect(new Set(childNodes.map((node) => node.x)).size).toBe(4); expect(aspectRatio).toBeGreaterThanOrEqual(1.41); expect(aspectRatio).toBeLessThanOrEqual(1.61); }); it("keeps direct siblings on one level in top-down mode", () => { const children = Array.from({ length: 13 }, (_, index) => orgNode(`child-${index + 1}`, [], 1), ); const layout = layoutForest([orgNode("root", children)], new Set(), { childLayoutMode: "topDown", }); const childNodes = layout.nodes.filter((node) => node.node.id.startsWith("child-"), ); const uniqueChildRows = new Set(childNodes.map((node) => node.y)); expect(childNodes).toHaveLength(13); expect(uniqueChildRows.size).toBe(1); }); it("places children in three fixed columns with centered parent edges", () => { const children = Array.from({ length: 10 }, (_, index) => orgNode(`child-${index + 1}`, [], 1), ); const layout = layoutForest([orgNode("root", children)], new Set(), { childLayoutMode: "threeColumn", }); const childNodes = layout.nodes.filter((node) => node.node.id.startsWith("child-"), ); const uniqueChildColumns = new Set(childNodes.map((node) => node.x)); const uniqueChildRows = new Set(childNodes.map((node) => node.y)); const rootEdges = layout.edges.filter((edge) => edge.key.startsWith("root->"), ); expect(uniqueChildColumns.size).toBe(3); expect(uniqueChildRows.size).toBe(4); expect(rootEdges).toHaveLength(10); expect(rootEdges.filter((edge) => edge.visibleByDefault)).toHaveLength(3); }); it("places the deepest child subtree in the first multi-column section", () => { const children = [ orgNode("shallow-1", [], 1), orgNode("shallow-2", [], 1), orgNode("shallow-3", [], 1), orgNode( "deep", [ orgNode( "deep-branch", [orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)], 2, ), ], 1, ), orgNode("shallow-4", [], 1), orgNode("shallow-5", [], 1), ]; const layout = layoutForest([orgNode("root", children)], new Set(), { childLayoutMode: "threeColumn", }); const rootEdges = layout.edges.filter((edge) => edge.key.startsWith("root->"), ); expect(rootEdges.map((edge) => edge.key)).toContain("root->deep"); }); it("centers a parent over the full child span in multi-column mode", () => { const children = [ orgNode( "deep", [ orgNode( "deep-branch", [orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)], 2, ), ], 1, ), ...Array.from({ length: 9 }, (_, index) => orgNode(`shallow-${index + 1}`, [], 1), ), ]; const layout = layoutForest([orgNode("root", children)], new Set(), { childLayoutMode: "threeColumn", }); const rootNode = layout.nodes.find((node) => node.node.id === "root"); const directChildren = layout.nodes.filter((node) => node.node.level === 1); const childSpanCenter = (Math.min(...directChildren.map((node) => node.x + node.width / 2)) + Math.max(...directChildren.map((node) => node.x + node.width / 2))) / 2; const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0; expect(rootNode).toBeDefined(); expect(rootCenter).toBeCloseTo(childSpanCenter, 5); }); it("centers parents above the tidy child span", () => { const children = [ orgNode("left", [orgNode("left-a", [], 2), orgNode("left-b", [], 2)], 1), orgNode("middle", [], 1), orgNode( "right", [orgNode("right-a", [], 2), orgNode("right-b", [], 2)], 1, ), ]; const layout = layoutForest([orgNode("root", children)], new Set(), { childLayoutMode: "topDown", }); const rootNode = layout.nodes.find((node) => node.node.id === "root"); const directChildren = layout.nodes.filter((node) => ["left", "middle", "right"].includes(node.node.id), ); const childSpanCenter = (Math.min(...directChildren.map((node) => node.x + node.width / 2)) + Math.max(...directChildren.map((node) => node.x + node.width / 2))) / 2; const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0; expect(rootNode).toBeDefined(); expect(rootCenter).toBeCloseTo(childSpanCenter, 5); }); it("keeps compressed subtrees from overlapping on shared vertical bands", () => { const layout = layoutForest( [ orgNode("root", [ orgNode( "left", [orgNode("left-a", [], 2), orgNode("left-b", [], 2)], 1, ), orgNode( "right", [orgNode("right-a", [], 2), orgNode("right-b", [], 2)], 1, ), ]), ], new Set(), ); for (const node of layout.nodes) { for (const other of layout.nodes) { if (node.node.id >= other.node.id) continue; const verticalOverlap = node.y < other.y + other.height && other.y < node.y + node.height; const horizontalOverlap = node.x < other.x + other.width && other.x < node.x + node.width; expect( verticalOverlap && horizontalOverlap, `${node.node.id} overlaps ${other.node.id}`, ).toBe(false); } } }); it("keeps zoom limits wide enough for large SVG organization charts", () => { expect(clampScale(0.08)).toBe(0.08); expect(clampScale(32)).toBe(32); expect(clampScale(64)).toBe(32); }); it("switches semantic zoom modes from overview to detail", () => { expect(getSemanticZoomMode(0.12)).toBe("overview"); expect(getSemanticZoomMode(0.4)).toBe("compact"); expect(getSemanticZoomMode(0.8)).toBe("detail"); }); it("uses distinct header fills by organization depth", () => { expect(getOrgNodeHeaderFill(0, "family")).toBe("#000000"); expect(getOrgNodeHeaderFill(0, "saman")).toBe("#f58220"); expect(getOrgNodeHeaderFill(0, "hanmac")).toBe("#1e489d"); expect(getOrgNodeHeaderFill(0, "gpdtdc")).toBe("#4b746d"); expect(getOrgNodeHeaderFill(0, "baron")).toBe("#004cbf"); expect(getOrgNodeHeaderFill(1, "saman")).not.toBe( getOrgNodeHeaderFill(0, "saman"), ); expect(getOrgNodeHeaderFill(2, "saman")).not.toBe( getOrgNodeHeaderFill(1, "saman"), ); }); it("orders top organization choices by the hanmac family policy", () => { const familyRoot = tenantNode( "family", "COMPANY_GROUP", "한맥가족", "hanmac-family", [ tenantNode("saman", "COMPANY", "삼안", "saman"), tenantNode("baron", "COMPANY_GROUP", "바론그룹", "baron-group"), tenantNode("hanmac", "COMPANY", "한맥기술", "hanmac"), tenantNode("gpdtdc", "ORGANIZATION", "총괄기획&기술개발센터", "gpdtdc"), ], ); expect( buildOrgSelectionOptions(familyRoot).map((option) => option.label), ).toEqual(["총괄기획&기술개발센터", "삼안", "한맥기술", "바론그룹"]); }); it("selects hanmac family as the default root even when public sector group is listed first", () => { const publicSector = tenantNode( "public-sector", "COMPANY_GROUP", "공공기관", "public-sector", ); const familyRoot = tenantNode( "family", "COMPANY_GROUP", "한맥가족", "hanmac-family", [tenantNode("saman", "COMPANY", "삼안", "saman")], ); expect(resolveOrgChartFamilyRoot([publicSector, familyRoot])?.id).toBe( "family", ); }); it("hides internal organizations by default and includes them for internal mode", () => { const visibleParent = tenantNode( "visible-parent", "COMPANY", "공개 회사", "visible-parent", ); const internalOrg = { ...tenantNode( "internal-org", "ORGANIZATION", "내부 조직", "internal-org", ), parentId: "visible-parent", config: { visibility: "internal" }, }; const internalChild = { ...tenantNode( "internal-child", "ORGANIZATION", "내부 하위", "internal-child", ), parentId: "internal-org", }; const privateOrg = { ...tenantNode( "private-org", "ORGANIZATION", "비공개 조직", "private-org", ), parentId: "visible-parent", config: { visibility: "private" }, }; const publicOrg = { ...tenantNode("public-org", "ORGANIZATION", "공개 조직", "public-org"), parentId: "visible-parent", }; const tenants = [ visibleParent, internalOrg, internalChild, privateOrg, publicOrg, ]; expect( filterSystemGlobalTenants(tenants, "public").map((tenant) => tenant.id), ).toEqual(["visible-parent", "public-org"]); expect( filterSystemGlobalTenants(tenants, "internal").map((tenant) => tenant.id), ).toEqual([ "visible-parent", "internal-org", "internal-child", "public-org", ]); }); it("maps legacy companyCode users to matching tenant slugs", () => { const usersMap = buildUsersMap( [ { ...member("engineering-user"), companyCode: "engineering", tenantSlug: undefined, tenant: undefined, joinedTenants: undefined, }, ], [tenantNode("engineering", "ORGANIZATION", "Engineering", "engineering")], { activeOnly: true }, ); expect(usersMap.get("engineering")?.map((user) => user.id)).toEqual([ "engineering-user", ]); }); it("maps GPDTDC representative users to their visible leaf appointment", () => { const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc"); const tdc = { ...tenantNode("tdc", "ORGANIZATION", "기술개발센터", "tdc"), parentId: "gpdtdc", }; const leaf = { ...tenantNode("tdc-leaf", "USER_GROUP", "기술개발센터 1팀", "tdc-leaf"), parentId: "tdc", }; const rootNode = { ...gpdtdc, children: [{ ...tdc, children: [leaf] }], }; const usersMap = buildUsersMap( [ { ...member("gpdtdc-user"), companyCode: undefined, tenantSlug: "gpdtdc", metadata: { additionalAppointments: [ { tenantSlug: "internal-planning", isPrimary: true, }, { tenantSlug: "tdc-leaf", isPrimary: false, grade: "책임", position: "팀장", }, ], }, joinedTenants: undefined, }, ], [rootNode], { activeOnly: true }, ); expect(usersMap.get("gpdtdc")).toBeUndefined(); expect(usersMap.get("internal-planning")).toBeUndefined(); expect(usersMap.get("tdc-leaf")?.map((user) => user.id)).toEqual([ "gpdtdc-user", ]); }); it("does not fall back to a visible parent for hidden leaf memberships", () => { const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc"); const internalLeaf = { ...tenantNode( "internal-leaf", "USER_GROUP", "내부 구성 조직", "internal-leaf", ), parentId: "gpdtdc", }; const usersMap = buildUsersMap( [ { ...member("hidden-only-user"), companyCode: undefined, tenantSlug: "gpdtdc", metadata: { additionalAppointments: [ { tenantSlug: "internal-leaf", isPrimary: true, }, ], }, joinedTenants: undefined, }, ], [gpdtdc], { activeOnly: true, membershipRootNodes: [{ ...gpdtdc, children: [internalLeaf] }], }, ); expect(usersMap.get("gpdtdc")).toBeUndefined(); expect(usersMap.get("internal-leaf")).toBeUndefined(); }); });