import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; import type { Locator, Page } from "@playwright/test"; const interactiveActionRoles = ["button", "link", "menuitem"] as const; const validPngBytes = [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 255, 255, 63, 0, 5, 254, 2, 254, 167, 53, 129, 132, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] as const; async function expectNoHorizontalOverflow(page: Page): Promise { const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, ); expect(hasHorizontalOverflow).toBe(false); } async function zoomTo200Percent(page: Page): Promise { await page.evaluate(() => { document.documentElement.style.zoom = "2"; }); } async function expectNoCriticalOrSeriousAxeViolations(page: Page): Promise { const results = await new AxeBuilder({ page }).analyze(); const blockingViolations = results.violations.filter( (violation) => violation.impact === "critical" || violation.impact === "serious", ); expect(blockingViolations).toEqual([]); } async function expectNoRawSeriesDisplayText(page: Page): Promise { await expect(page.getByText(/SUN|PROCEEDING|isAdult true|isActive true|genreId/)).toBeHidden(); } async function expectActionAbsentAcrossInteractiveRoles(page: Page, name: string): Promise { for (const role of interactiveActionRoles) { await expect(page.getByRole(role, { name })).toBeHidden(); } } async function pressTabUntilFocused(page: Page, target: Locator): Promise { for (let attempt = 0; attempt < 40; attempt += 1) { if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) { return; } await page.keyboard.press("Tab"); } await expect(target).toBeFocused(); } async function loginThroughMockMode(page: Page): Promise { await page.goto("/login"); await page.getByLabel("이메일").fill("admin@test.com"); await page.getByLabel("비밀번호").fill("password"); await page.getByRole("button", { name: "로그인" }).click(); await expect(page).toHaveURL(/\/ai-characters$/); } async function setSeriesImageFile(page: Page): Promise { await page.locator('input[type="file"][accept="image/jpeg,image/png"]').evaluate((element, bytes) => { if (!(element instanceof HTMLInputElement)) { throw new Error("Series image input not found"); } const transfer = new DataTransfer(); transfer.items.add(new File([new Uint8Array(bytes)], "series.png", { type: "image/png" })); element.files = transfer.files; element.dispatchEvent(new Event("change", { bubbles: true })); }, validPngBytes); } async function openSeriesDetail(page: Page): Promise { await page.goto("/ai-characters/101/series/5001"); await expect(page.getByRole("heading", { name: "달빛 상담 시리즈", exact: true })).toBeVisible(); } async function linkMorningAudio(page: Page): Promise { await page.getByRole("searchbox", { name: "검색어" }).fill("아침"); await page.getByRole("checkbox", { name: /아침 안내 오디오/ }).click(); await page.getByRole("button", { name: "선택한 오디오 연결" }).click(); await expect(page.getByRole("button", { name: "아침 안내 오디오 연결 해제" })).toBeVisible(); } test("mobile route capability keeps Series list detail and linked content read-only", async ({ page }) => { // Given await page.setViewportSize({ width: 320, height: 640 }); await loginThroughMockMode(page); // When / Then await page.goto("/ai-characters/101/series"); await expect(page.getByRole("heading", { name: "시리즈", exact: true })).toBeVisible(); const seriesCard = page.getByRole("link", { name: "달빛 상담 시리즈 상세 보기" }); await expect(seriesCard).toBeVisible(); await expect(seriesCard).toContainText("일, 수 · 로맨스"); await expect(seriesCard).toContainText("연재중"); await expect(seriesCard).toContainText("전체 이용 · 활성"); await expectNoRawSeriesDisplayText(page); await expectActionAbsentAcrossInteractiveRoles(page, "시리즈 생성"); await expect(page.getByText("시리즈 생성과 수정은 태블릿 이상 화면에서 이용할 수 있습니다.")).toBeVisible(); await expectNoHorizontalOverflow(page); await openSeriesDetail(page); const seriesSummary = page.getByLabel("시리즈 요약"); await expect(seriesSummary).toContainText("로맨스"); await expect(seriesSummary).toContainText("일, 수"); await expect(seriesSummary).toContainText("연재중"); await expect(seriesSummary).toContainText("전체 이용"); await expect(seriesSummary).toContainText("활성"); await expectNoRawSeriesDisplayText(page); await expect(page.getByRole("heading", { name: "연결된 오디오", exact: true })).toBeVisible(); await expect(page.getByText("달빛 상담 오디오", { exact: true })).toBeVisible(); await expect(page.getByRole("searchbox", { name: "검색어" })).toBeHidden(); await expect(page.getByRole("button", { name: "달빛 상담 오디오 연결 해제" })).toBeHidden(); await expect(page.getByRole("button", { name: "선택한 오디오 연결" })).toBeHidden(); await expect(page.getByRole("link", { name: "전체 시리즈 순서 관리" })).toBeHidden(); await expectActionAbsentAcrossInteractiveRoles(page, "수정"); await expectActionAbsentAcrossInteractiveRoles(page, "비활성화"); await expectNoHorizontalOverflow(page); await page.goto("/ai-characters/101/series/order"); await expect(page.getByRole("heading", { name: "태블릿에서 시리즈 순서를 관리해 주세요" })).toBeVisible(); await expect(page.getByRole("list", { name: "시리즈 순서 목록" })).toBeHidden(); await expect(page.getByRole("button", { name: "순서 저장" })).toBeHidden(); await expectNoHorizontalOverflow(page); }); test("desktop Series CRUD uses genre lookup, crop, edit initialization, and soft delete", async ({ page }) => { // Given await page.setViewportSize({ width: 1280, height: 900 }); await loginThroughMockMode(page); // When await page.goto("/ai-characters/101/series"); await expect(page.getByRole("region", { name: "시리즈 목록" })).toContainText("로맨스"); await expectNoRawSeriesDisplayText(page); await page.getByRole("link", { name: "시리즈 생성" }).click(); // Then await expect(page.getByRole("heading", { name: "시리즈 생성" })).toBeVisible(); await expect(page.getByLabel("장르")).toContainText("로맨스"); // When await page.getByLabel("제목").fill("새 시리즈"); await page.getByLabel("소개").fill("새로운 시리즈 소개"); const keywordInput = page.getByRole("textbox", { name: "키워드" }); await keywordInput.fill("달빛"); await keywordInput.press("Enter"); await keywordInput.fill("상담"); await keywordInput.press(","); await expect(page.getByRole("button", { name: "키워드 달빛 삭제" })).toBeVisible(); await expect(page.getByRole("button", { name: "키워드 상담 삭제" })).toBeVisible(); await page.getByRole("checkbox", { name: "월요일" }).check(); await page.getByLabel("장르").selectOption("77"); await setSeriesImageFile(page); await expect(page.getByRole("dialog", { name: "이미지 crop" })).toBeVisible(); await page.getByRole("button", { name: "적용" }).click(); await expect(page.getByRole("dialog", { name: "이미지 crop" })).toBeHidden(); await page.getByRole("button", { name: "생성" }).click(); // Then await expect(page).toHaveURL(/\/ai-characters\/101\/series$/); await expect(page.getByText("시리즈를 생성했습니다.")).toBeVisible(); await expect(page.getByRole("link", { name: "새 시리즈 상세 보기" })).toBeVisible(); // When await page.getByRole("link", { name: "새 시리즈 상세 보기" }).click(); await page.getByRole("link", { name: "수정" }).click(); // Then await expect(page.getByRole("heading", { name: "시리즈 수정" })).toBeVisible(); await expect(page.getByLabel("제목")).toHaveValue("새 시리즈"); await expect(page.getByLabel("상태", { exact: true })).toBeVisible(); await expect(page.getByLabel("키워드")).toBeHidden(); // When await page.getByLabel("상태", { exact: true }).selectOption("COMPLETE"); await page.getByRole("button", { name: "저장" }).click(); // Then await expect(page).toHaveURL(/\/ai-characters\/101\/series\/\d+$/); await expect(page.getByText("시리즈를 저장했습니다.")).toBeVisible(); // When await page.getByRole("link", { name: "수정" }).click(); await page.getByRole("button", { name: "비활성화" }).click(); await page.getByRole("button", { name: "비활성화 확인" }).click(); // Then await expect(page).toHaveURL(/\/ai-characters\/101\/series$/); await expect(page.getByText("시리즈를 비활성화했습니다.")).toBeVisible(); }); for (const width of [768, 1280] as const) { test(`desktop and tablet Series management flow remains available at ${width}px`, async ({ page }) => { // Given await page.setViewportSize({ width, height: 900 }); await loginThroughMockMode(page); await openSeriesDetail(page); // When / Then await expect(page.getByRole("link", { name: "전체 시리즈 순서 관리" })).toBeVisible(); await linkMorningAudio(page); await page.getByRole("button", { name: "아침 안내 오디오 연결 해제" }).click(); await expect(page.getByRole("alertdialog", { name: "오디오 연결을 해제하시겠습니까?" })).toContainText("아침 안내 오디오"); await page.getByRole("button", { name: "연결 해제", exact: true }).click(); await expect(page.getByRole("button", { name: "아침 안내 오디오 연결 해제" })).toBeHidden(); await page.getByRole("link", { name: "전체 시리즈 순서 관리" }).click(); await expect(page.getByRole("heading", { name: "시리즈 순서 관리" })).toBeVisible(); await page.getByRole("button", { name: "아침 루틴 시리즈 위로" }).click(); await page.getByRole("button", { name: "순서 저장" }).click(); await expect(page.getByText("시리즈 순서를 저장했습니다")).toBeVisible(); }); } test("keyboard-only Series interactions cover link unlink dialog and order save", async ({ browserName, isMobile, page }) => { test.skip(isMobile, "Mobile view intentionally blocks Series mutation actions."); test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path."); // Given await page.setViewportSize({ width: 1280, height: 900 }); await loginThroughMockMode(page); await openSeriesDetail(page); // When / Then const searchInput = page.getByRole("searchbox", { name: "검색어" }); await pressTabUntilFocused(page, searchInput); await page.keyboard.type("아침"); const candidate = page.getByRole("checkbox", { name: /아침 안내 오디오/ }); await pressTabUntilFocused(page, candidate); await page.keyboard.press("Space"); await pressTabUntilFocused(page, page.getByRole("button", { name: "선택한 오디오 연결" })); await page.keyboard.press("Enter"); await expect(page.getByRole("button", { name: "아침 안내 오디오 연결 해제" })).toBeVisible(); const unlinkButton = page.getByRole("button", { name: "아침 안내 오디오 연결 해제" }); await pressTabUntilFocused(page, unlinkButton); await page.keyboard.press("Enter"); const dialog = page.getByRole("alertdialog", { name: "오디오 연결을 해제하시겠습니까?" }); await expect(dialog).toBeVisible(); await expect(dialog.getByRole("button", { name: "취소" })).toBeFocused(); await page.keyboard.press("Escape"); await expect(dialog).toBeHidden(); await expect(unlinkButton).toBeFocused(); await pressTabUntilFocused(page, page.getByRole("link", { name: "전체 시리즈 순서 관리" })); await page.keyboard.press("Enter"); await expect(page.getByRole("heading", { name: "시리즈 순서 관리" })).toBeVisible(); await pressTabUntilFocused(page, page.getByRole("button", { name: "아침 루틴 시리즈 위로" })); await page.keyboard.press("Enter"); await pressTabUntilFocused(page, page.getByRole("button", { name: "순서 저장" })); await page.keyboard.press("Enter"); await expect(page.getByText("시리즈 순서를 저장했습니다")).toBeVisible(); }); test("Series list detail and order keep 200 percent zoom and axe coverage across route capability widths", async ({ page }) => { // Given await loginThroughMockMode(page); for (const width of [320, 768, 1280] as const) { for (const route of ["/ai-characters/101/series", "/ai-characters/101/series/5001", "/ai-characters/101/series/order"] as const) { await page.setViewportSize({ width, height: 900 }); await page.goto(route); // When await zoomTo200Percent(page); // Then await expectNoHorizontalOverflow(page); await expectNoCriticalOrSeriousAxeViolations(page); } } }); test("series detail links unlinked audio, unlinks with confirmation, and persists keyboard order", async ({ page }) => { // Given await page.setViewportSize({ width: 1280, height: 900 }); await loginThroughMockMode(page); // When await page.goto("/ai-characters/101/series/5001"); // Then await expect(page.getByRole("heading", { name: "달빛 상담 시리즈", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "연결된 오디오", exact: true })).toBeVisible(); await expect(page.getByText("총 1개 · 1페이지")).toBeVisible(); await expect(page.getByRole("button", { name: "달빛 상담 오디오 연결 해제" })).toBeVisible(); // When await page.getByRole("searchbox", { name: "검색어" }).fill("아침"); await page.getByRole("checkbox", { name: /아침 안내 오디오/ }).click(); await page.getByRole("button", { name: "선택한 오디오 연결" }).click(); // Then await expect(page.getByRole("button", { name: "아침 안내 오디오 연결 해제" })).toBeVisible(); await expect(page.getByRole("checkbox", { name: /아침 안내 오디오/ })).toBeHidden(); // When await page.getByRole("button", { name: "달빛 상담 오디오 연결 해제" }).click(); // Then await expect(page.getByRole("alertdialog", { name: "오디오 연결을 해제하시겠습니까?" })).toContainText("달빛 상담 오디오"); // When await page.getByRole("button", { name: "연결 해제", exact: true }).click(); // Then await expect(page.getByRole("button", { name: "달빛 상담 오디오 연결 해제" })).toBeHidden(); // When await page.getByRole("link", { name: "전체 시리즈 순서 관리" }).click(); // Then await expect(page.getByRole("heading", { name: "시리즈 순서 관리" })).toBeVisible(); // When await page.getByRole("button", { name: "아침 루틴 시리즈 위로" }).click(); await page.getByRole("button", { name: "순서 저장" }).click(); await page.goBack(); await page.getByRole("link", { name: "전체 시리즈 순서 관리" }).click(); // Then await expect(page.getByRole("heading", { name: "시리즈 순서 관리" })).toBeVisible(); const firstRow = page.getByRole("list", { name: "시리즈 순서 목록" }).getByRole("listitem").first(); await expect(firstRow).toContainText("아침 루틴 시리즈"); });