1
0
forked from baron/baron-sso
Files
baron-sso/orgfront/tests/orgchart-pan-zoom.spec.ts
2026-05-29 10:33:15 +09:00

680 lines
22 KiB
TypeScript

import { expect, test } from "@playwright/test";
type TenantFixture = {
id: string;
type: string;
name: string;
slug: string;
description: string;
status: string;
parentId?: string;
config?: Record<string, unknown>;
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: "사원",
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/tenants**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: [
{
...tenant("group", "Baron Group", "baron"),
type: "COMPANY_GROUP",
},
tenant("engineering", "Engineering", "engineering", "group"),
],
limit: 10000,
offset: 0,
total: 2,
}),
});
});
await page.route("**/api/v1/admin/users**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: [user("u-eng", "Engineering User", "engineering")],
limit: 5000,
offset: 0,
total: 1,
}),
});
});
await page.goto("/chart");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
await expect(page.getByText("MH Dashboard", { 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", /3\d{2}/);
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();
});