import { expect, test } from "@playwright/test"; type TenantFixture = { id: string; type: string; name: string; slug: string; description: string; status: string; parentId?: string; config?: Record; memberCount: number; createdAt: string; updatedAt: string; }; function tenant( id: string, name: string, slug: string, parentId?: string, ): TenantFixture { return { id, type: parentId ? "USER_GROUP" : "COMPANY", name, slug, description: "", status: "active", parentId, memberCount: 1, createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", }; } function user(id: string, name: string, companyCode: string) { return { id, email: `${id}@example.com`, name, role: "user", status: "active", companyCode, grade: "사원", metadata: { additionalAppointments: [{ tenantSlug: companyCode }], }, createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", }; } test("org chart viewport pans with drag and zooms with the mouse wheel", async ({ page, }) => { await page.route("**/api/v1/public/orgchart**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ sharedWith: "Playwright", tenants: [ tenant("root", "Baron Group", "baron"), tenant("engineering", "Engineering", "engineering", "root"), tenant("platform", "Platform", "platform", "engineering"), tenant("security", "Security", "security", "engineering"), tenant("product", "Product", "product", "root"), tenant("design", "Design", "design", "product"), tenant("operations", "Operations", "operations", "root"), ], users: [ user("u-root", "Root User", "baron"), user("u-eng", "Engineering User", "engineering"), user("u-platform", "Platform User", "platform"), user("u-security", "Security User", "security"), user("u-product", "Product User", "product"), user("u-design", "Design User", "design"), user("u-ops", "Operations User", "operations"), ], }), }); }); await page.goto("/chart?token=pan-zoom"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); const viewport = page.locator('[data-testid="orgchart-viewport"]'); const canvas = page.locator('[data-testid="orgchart-canvas"]'); const svg = page.locator('[data-testid="orgchart-vector-svg"]'); await expect(viewport).toBeVisible(); await expect(canvas).toBeVisible(); await expect(svg).toBeVisible(); await expect .poll(async () => viewport.evaluate((element) => { const style = window.getComputedStyle(element); return `${style.overflowX}/${style.overflowY}`; }), ) .toBe("hidden/hidden"); const initialViewBox = await svg.getAttribute("viewBox"); const box = await viewport.boundingBox(); expect(box).not.toBeNull(); if (!box) return; await page.mouse.move(box.x + 24, box.y + box.height - 24); await page.mouse.down(); await page.mouse.move(box.x + 164, box.y + box.height - 104); await page.mouse.up(); await expect .poll(async () => svg.getAttribute("viewBox")) .not.toBe(initialViewBox); const afterDragViewBox = await svg.getAttribute("viewBox"); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.wheel(0, -500); await expect .poll(async () => svg.getAttribute("viewBox")) .not.toBe(afterDragViewBox); const scale = await svg.evaluate((element) => Number.parseFloat(element.getAttribute("data-scale") ?? "1"), ); expect(scale).toBeGreaterThan(1); }); test("org chart dashboard uses the full screen below the orgfront topbar", async ({ page, }) => { await page.route("**/api/v1/public/orgchart**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ sharedWith: "Playwright", tenants: [ tenant("root", "Baron Group", "baron"), tenant("engineering", "Engineering", "engineering", "root"), ], users: [ user("u-root", "Root User", "baron"), user("u-eng", "Engineering User", "engineering"), ], }), }); }); await page.goto("/chart?token=full-screen"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); const metrics = await page.evaluate(() => { const topbar = document .querySelector('[data-testid="orgfront-topbar"]') ?.getBoundingClientRect(); const main = document .querySelector('[data-testid="orgfront-main"]') ?.getBoundingClientRect(); const shell = document .querySelector('[data-testid="orgchart-dashboard-shell"]') ?.getBoundingClientRect(); if (!topbar || !main || !shell) { throw new Error("Missing org chart layout elements"); } return { innerHeight: window.innerHeight, innerWidth: window.innerWidth, mainTop: main.top, shellBottom: shell.bottom, shellLeft: shell.left, shellRight: shell.right, shellTop: shell.top, topbarBottom: topbar.bottom, }; }); expect(Math.abs(metrics.mainTop - metrics.topbarBottom)).toBeLessThanOrEqual( 1, ); expect(metrics.shellTop).toBe(metrics.topbarBottom); expect(metrics.shellLeft).toBeLessThanOrEqual(1); expect(metrics.shellRight).toBeGreaterThanOrEqual(metrics.innerWidth - 1); expect(metrics.shellBottom).toBeGreaterThanOrEqual(metrics.innerHeight - 1); }); test("org chart non-shared title does not render the MH Dashboard eyebrow", async ({ page, }) => { await page.addInitScript(() => { window.localStorage.setItem("playwright_auth_bypass", "1"); window.localStorage.setItem("dev_tenant_id", "group"); }); await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ tenants: [ { ...tenant("group", "Baron Group", "baron"), type: "COMPANY_GROUP", }, tenant("engineering", "Engineering", "engineering", "group"), ], users: [user("u-eng", "Engineering User", "engineering")], cache: { source: "redis", hit: true }, }), }); }); await page.goto("/chart"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); await expect(page.getByText("MH Dashboard", { exact: true })).toHaveCount(0); }); test("org chart toggles internal organizations from the total users tooltip", async ({ page, }) => { await page.addInitScript(() => { window.localStorage.setItem("playwright_auth_bypass", "1"); window.localStorage.setItem("dev_tenant_id", "group"); }); await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ tenants: [ { ...tenant("group", "한맥가족", "hanmac-family"), type: "COMPANY_GROUP", }, tenant("company", "삼안", "saman", "group"), { ...tenant("open-team", "공개 팀", "open-team", "company"), config: { visibility: "public" }, }, { ...tenant("internal-team", "내부 팀", "internal-team", "company"), config: { visibility: "internal" }, }, ], users: [ user("u-open", "Open User", "open-team"), user("u-internal", "Internal User", "internal-team"), ], cache: { source: "redis", hit: true }, }), }); }); await page.goto("/chart"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); const svg = page.locator('[data-testid="orgchart-vector-svg"]'); const totalUsersControl = page.getByTestId("orgchart-total-users-control"); await expect(totalUsersControl).toHaveText("총 1명"); await expect(svg.getByText("공개 팀", { exact: true })).toBeVisible(); await expect(svg.getByText(/Open User/)).toBeVisible(); await expect(svg.getByText("내부 팀", { exact: true })).toHaveCount(0); await expect(svg.getByText("Internal User", { exact: true })).toHaveCount(0); await totalUsersControl.hover(); await page.getByRole("switch", { name: "내부조직 보기" }).setChecked(true); await expect(totalUsersControl).toHaveText("총 2명"); await expect(svg.getByText("내부 팀", { exact: true })).toBeVisible(); await expect(svg.getByText(/Internal User/)).toBeVisible(); }); test("org chart defaults to hanmac family when public sector group is listed first", async ({ page, }) => { await page.addInitScript(() => { window.localStorage.setItem("playwright_auth_bypass", "1"); window.localStorage.setItem("dev_tenant_id", "family"); }); await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ tenants: [ { ...tenant("public-sector", "공공기관", "public-sector"), type: "COMPANY_GROUP", memberCount: 0, }, { ...tenant("family", "한맥가족", "hanmac-family"), type: "COMPANY_GROUP", }, { ...tenant("saman", "삼안", "saman", "family"), type: "COMPANY", }, ], users: [user("u-saman", "Saman User", "saman")], cache: { source: "redis", hit: true }, }), }); }); await page.goto("/chart"); const svg = page.locator('[data-testid="orgchart-vector-svg"]'); await expect( page.getByRole("button", { name: "조직: 한맥가족" }), ).toBeVisible(); await expect(svg.getByText("한맥가족", { exact: true })).toBeVisible(); await expect(svg.getByText("삼안", { exact: true })).toBeVisible(); await expect(svg.getByText("공공기관", { exact: true })).toHaveCount(0); }); test("org chart renders dense member nodes with calculated member columns", async ({ page, }) => { const denseUsers = Array.from({ length: 10 }, (_, index) => user(`u-dense-${index + 1}`, `Dense User ${index + 1}`, "baron"), ); await page.route("**/api/v1/public/orgchart**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ sharedWith: "Playwright", tenants: [tenant("root", "Baron Group", "baron")], users: denseUsers, }), }); }); await page.goto("/chart?token=dense-members"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); const rootNode = page.locator('[data-testid="orgchart-node-root"]'); await expect(rootNode).toHaveAttribute("width", /\d+/); expect(Number(await rootNode.getAttribute("width"))).toBeGreaterThan(240); await expect(rootNode.locator('[data-member-columns="2"]')).toBeVisible(); await expect(rootNode.getByText("Dense User 10")).toBeVisible(); }); test("public org chart hides internal and private tenants and renders org unit type", async ({ page, }) => { await page.route("**/api/v1/public/orgchart**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ sharedWith: "Playwright", tenants: [ { ...tenant("group", "한맥가족", "hanmac-family"), type: "COMPANY_GROUP", }, tenant("company", "삼안", "saman", "group"), { ...tenant("open-team", "공개 팀", "open-team", "company"), config: { orgUnitType: "팀", visibility: "public" }, }, { ...tenant("internal-team", "내부 팀", "internal-team", "company"), config: { visibility: "internal" }, }, { ...tenant("private-team", "비공개 팀", "private-team", "company"), config: { visibility: "private" }, }, tenant( "private-child", "비공개 하위", "private-child", "private-team", ), ], users: [ user("u-open", "Open User", "open-team"), user("u-internal", "Internal User", "internal-team"), user("u-private", "Private User", "private-team"), user("u-private-child", "Private Child User", "private-child"), ], }), }); }); await page.goto("/chart?token=tenant-visibility"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); const svg = page.locator('[data-testid="orgchart-vector-svg"]'); await expect(svg.getByText("공개 팀", { exact: true })).toBeVisible(); await expect(svg.getByText("팀", { exact: true })).toBeVisible(); await expect(svg.getByText(/Open User/)).toBeVisible(); await expect(svg.getByText("내부 팀", { exact: true })).toHaveCount(0); await expect(svg.getByText("Internal User", { exact: true })).toHaveCount(0); await expect(svg.getByText("비공개 팀", { exact: true })).toHaveCount(0); await expect(svg.getByText("Private User", { exact: true })).toHaveCount(0); await expect(svg.getByText("비공개 하위", { exact: true })).toHaveCount(0); await expect( svg.getByText("Private Child User", { exact: true }), ).toHaveCount(0); }); test("org chart colors hanmac family and nested baron company group separately", async ({ page, }) => { await page.route("**/api/v1/public/orgchart**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ sharedWith: "Playwright", tenants: [ { ...tenant("family", "한맥가족", "hanmac-family"), type: "COMPANY_GROUP", }, { ...tenant("baron-group", "Baron Group", "baron-group", "family"), type: "COMPANY_GROUP", }, { ...tenant("baron-company", "Baron Company", "baron", "baron-group"), type: "COMPANY", }, ], users: [user("u-baron", "Baron User", "baron")], }), }); }); await page.goto("/chart?token=baron-group-color"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); const svg = page.locator('[data-testid="orgchart-vector-svg"]'); await expect(svg.getByText("Baron Group", { exact: true })).toBeVisible(); const colors = await page.evaluate(() => { function headerColor(nodeId: string) { const node = document.querySelector( `[data-testid="orgchart-node-${nodeId}"]`, ); const header = node?.querySelector("div > div"); return header ? window.getComputedStyle(header).backgroundColor : ""; } return { baronCompany: headerColor("baron-company"), baronGroup: headerColor("baron-group"), family: headerColor("family"), }; }); expect(colors.family).toBe("rgb(0, 0, 0)"); expect(colors.baronGroup).toBe("rgb(0, 76, 191)"); expect(colors.baronCompany).toBe("rgb(0, 76, 191)"); }); test("org chart orders top organization choices by the hanmac family policy", async ({ page, }) => { await page.route("**/api/v1/public/orgchart**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ sharedWith: "Playwright", tenants: [ { ...tenant("family", "한맥가족", "hanmac-family"), type: "COMPANY_GROUP", }, { ...tenant("saman", "삼안", "saman", "family"), type: "COMPANY", }, { ...tenant("baron-group", "바론그룹", "baron-group", "family"), type: "COMPANY_GROUP", }, { ...tenant("hanmac", "한맥기술", "hanmac", "family"), type: "COMPANY", }, { ...tenant("gpdtdc", "총괄기획&기술개발센터", "gpdtdc", "family"), type: "ORGANIZATION", }, ], users: [], }), }); }); await page.goto("/chart?token=org-selection-order"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); const labels = await page .getByTestId("orgchart-org-selector") .locator("button") .evaluateAll((buttons) => buttons.map((button) => button.textContent?.trim() ?? ""), ); expect(labels.slice(0, 5)).toEqual([ "한맥가족", "총괄기획&기술개발센터", "삼안", "한맥기술", "바론그룹", ]); }); test("org chart compresses many sibling organizations and allows wide zoom out", async ({ page, }) => { const childTenants = Array.from({ length: 13 }, (_, index) => tenant( `team-${index + 1}`, `Team ${index + 1}`, `team-${index + 1}`, "root", ), ); await page.route("**/api/v1/public/orgchart**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ sharedWith: "Playwright", tenants: [tenant("root", "Baron Group", "baron"), ...childTenants], users: childTenants.map((child, index) => user(`u-team-${index + 1}`, `Team ${index + 1} User`, child.slug), ), }), }); }); await page.goto("/chart?token=wide-siblings"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); const viewport = page.locator('[data-testid="orgchart-viewport"]'); const svg = page.locator('[data-testid="orgchart-vector-svg"]'); await expect(svg).toBeVisible(); await expect(svg.getByText("Team 13", { exact: true })).toBeVisible(); await expect(svg.locator('foreignObject[data-node-id^="team-"]')).toHaveCount( 13, ); await expect( page.getByRole("button", { name: "조직: 한맥가족" }), ).toBeVisible(); await expect(page.getByText("배치", { exact: true })).toBeHidden(); await expect(page.getByRole("button", { name: "배치: 자동" })).toBeVisible(); await expect(page.getByText("연결", { exact: true })).toHaveCount(0); await expect(page.getByText("상위연결", { exact: true })).toHaveCount(0); const autoChildYPositions = await svg .locator('foreignObject[data-node-id^="team-"]') .evaluateAll((nodes) => nodes .map((node) => node.getAttribute("y") ?? "") .filter((value) => value.length > 0), ); expect(new Set(autoChildYPositions).size).toBeGreaterThan(1); await expect(svg.locator("path")).toHaveCount(13); await expect( svg.locator('path:not([data-hidden-default="true"])'), ).toHaveCount(4); await expect(svg.locator('path[data-hidden-default="true"]')).toHaveCount(9); await svg.locator('foreignObject[data-node-id="team-13"]').hover(); await expect(svg.locator('path[data-highlighted="true"]')).toHaveCount(1); await expect(svg.locator('path[data-muted="true"]')).toHaveCount(4); await page.getByTestId("orgchart-layout-mode-option").hover(); await expect(page.getByText("배치", { exact: true })).toBeVisible(); await expect( page.getByRole("button", { exact: true, name: "자동" }), ).toHaveCount(0); await page.getByRole("button", { name: "Top-down" }).click(); await expect .poll(async () => svg .locator('foreignObject[data-node-id^="team-"]') .evaluateAll( (nodes) => new Set( nodes .map((node) => node.getAttribute("y") ?? "") .filter((value) => value.length > 0), ).size, ), ) .toBe(1); await page.getByTestId("orgchart-layout-mode-option").hover(); await page.getByRole("button", { name: "3열" }).click(); const threeColumnPositions = await svg .locator('foreignObject[data-node-id^="team-"]') .evaluateAll((nodes) => nodes.map((node) => ({ x: node.getAttribute("x") ?? "", y: node.getAttribute("y") ?? "", })), ); expect(new Set(threeColumnPositions.map((position) => position.x)).size).toBe( 3, ); expect(new Set(threeColumnPositions.map((position) => position.y)).size).toBe( 5, ); await expect(svg.locator("path")).toHaveCount(13); await expect( svg.locator('path:not([data-hidden-default="true"])'), ).toHaveCount(3); const box = await viewport.boundingBox(); expect(box).not.toBeNull(); if (!box) return; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.wheel(0, 2500); await expect .poll(async () => svg.evaluate((element) => Number.parseFloat(element.getAttribute("data-scale") ?? "1"), ), ) .toBeLessThan(0.45); }); test("org chart selects first and second depth organizations from company hover choices", async ({ page, }) => { await page.route("**/api/v1/public/orgchart**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ sharedWith: "Playwright", tenants: [ { ...tenant("group", "Baron Group", "baron"), type: "COMPANY_GROUP", }, { ...tenant("company", "Company A", "company-a", "group"), type: "COMPANY", }, tenant("department", "Department A", "department-a", "company"), tenant("squad", "Squad A", "squad-a", "department"), tenant("team", "Team A", "team-a", "squad"), ], users: [ user("u-company", "Company User", "company-a"), user("u-department", "Department User", "department-a"), user("u-squad", "Squad User", "squad-a"), user("u-team", "Team User", "team-a"), ], }), }); }); await page.goto("/chart?token=company-depth-filter"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); await expect( page.getByRole("button", { name: "조직: 한맥가족" }), ).toBeVisible(); await expect(page.getByRole("button", { name: "Company A" })).toBeVisible(); await expect(page.getByText("하위범위", { exact: true })).toHaveCount(0); await expect(page.getByText("조직", { exact: true })).toHaveCount(0); await page.getByRole("button", { name: "Company A" }).click(); const svg = page.locator('[data-testid="orgchart-vector-svg"]'); await expect(svg.getByText("Team A", { exact: true })).toBeVisible(); await expect( page.getByRole("button", { name: "조직: Company A" }), ).toBeVisible(); await expect( page .getByTestId("orgchart-company-option-company") .getByRole("button", { name: "Company A" }), ).toBeVisible(); const orgButtonColor = await page .getByRole("button", { name: "조직: Company A" }) .evaluate((element) => window.getComputedStyle(element).backgroundColor); const layoutButtonColor = await page .getByRole("button", { name: "배치: 자동" }) .evaluate((element) => window.getComputedStyle(element).backgroundColor); expect(orgButtonColor).not.toBe(layoutButtonColor); await page.getByTestId("orgchart-company-option-company").hover(); await expect(svg.getByText("Department A", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "1뎁스 Department A" }).click(); await expect( page.getByRole("button", { name: "조직: Department A" }), ).toBeVisible(); await expect(svg.getByText("Squad A", { exact: true })).toBeVisible(); await page.getByTestId("orgchart-company-option-company").hover(); await page.getByRole("button", { name: "2뎁스 Squad A" }).click(); await expect( page.getByRole("button", { name: "조직: Squad A" }), ).toBeVisible(); await expect(svg.getByText("Squad A", { exact: true })).toBeVisible(); await expect(svg.getByText("Team A", { exact: true })).toBeVisible(); }); test("org chart uses semantic zoom to simplify deep nodes and restore labels on zoom in", async ({ page, }) => { await page.route("**/api/v1/public/orgchart**", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ sharedWith: "Playwright", tenants: [ tenant("root", "Baron Group", "baron"), tenant("department", "Archive Department", "department", "root"), tenant("division", "Archive Division", "division", "department"), tenant("deep", "Archive Deep Team", "deep", "division"), ], users: [ user("u-root", "Root User", "baron"), user("u-department", "Department User", "department"), user("u-division", "Division User", "division"), user("u-deep", "Deep User", "deep"), ], }), }); }); await page.goto("/chart?token=semantic-zoom"); await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible(); const viewport = page.locator('[data-testid="orgchart-viewport"]'); const svg = page.locator('[data-testid="orgchart-vector-svg"]'); const deepNode = svg.locator('foreignObject[data-node-id="deep"]'); await expect(svg).toHaveAttribute("data-semantic-zoom", "detail"); await expect(deepNode.getByText("Archive Deep Team")).toBeVisible(); const box = await viewport.boundingBox(); expect(box).not.toBeNull(); if (!box) return; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.wheel(0, 4000); await expect .poll(async () => svg.getAttribute("data-semantic-zoom")) .toBe("overview"); await expect(deepNode.getByText("Archive Deep Team")).toHaveCount(0); await page.mouse.wheel(0, -4000); await expect .poll(async () => svg.getAttribute("data-semantic-zoom")) .toBe("detail"); await expect(deepNode.getByText("Archive Deep Team")).toBeVisible(); });