Files
voiceon-character-admin/tests/e2e/audio-content.spec.ts

345 lines
16 KiB
TypeScript

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;
async function loginThroughMockMode(page: Page): Promise<void> {
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 openAudioList(page: Page): Promise<void> {
await page.goto("/ai-characters/101/audio-contents");
await expect(page.getByRole("heading", { name: "오디오 콘텐츠", exact: true })).toBeVisible();
}
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth);
expect(hasHorizontalOverflow).toBe(false);
}
async function zoomTo200Percent(page: Page): Promise<void> {
await page.evaluate(() => {
document.documentElement.style.zoom = "2";
});
}
async function expectNoCriticalOrSeriousAxeViolations(page: Page): Promise<void> {
const results = await new AxeBuilder({ page }).analyze();
const blockingViolations = results.violations.filter((violation) => violation.impact === "critical" || violation.impact === "serious");
expect(blockingViolations).toEqual([]);
}
async function expectActionAbsentAcrossInteractiveRoles(page: Page, name: string): Promise<void> {
for (const role of interactiveActionRoles) {
await expect(page.getByRole(role, { name })).toBeHidden();
}
}
async function expectResourceActionHidden(page: Page, name: string): Promise<void> {
await expect(page.locator("#audio-content-title").locator("..").locator("..").getByRole("button", { name })).toBeHidden();
}
async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> {
for (let attempt = 0; attempt < 36; attempt += 1) {
if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) {
return;
}
await page.keyboard.press("Tab");
}
await expect(target).toBeFocused();
}
test("mobile route capability keeps audio list detail and player available while blocking mutations", async ({ page }) => {
// Given
await page.setViewportSize({ width: 320, height: 640 });
await loginThroughMockMode(page);
// When / Then
await openAudioList(page);
await expectActionAbsentAcrossInteractiveRoles(page, "오디오 생성");
await expect(page.getByRole("group", { name: "달빛 상담 오디오 오디오 플레이어" })).toBeVisible();
await page.getByRole("link", { name: "달빛 상담 오디오 상세 보기" }).click();
await expect(page.getByRole("heading", { name: "달빛 상담 오디오", exact: true })).toBeVisible();
await expect(page.getByRole("group", { name: "달빛 상담 오디오 오디오 플레이어" })).toBeVisible();
await expectResourceActionHidden(page, "수정");
await page.goto("/ai-characters/101/audio-contents/new");
await expect(page.getByText(/데스크톱.*(생성|업로드|이용)/)).toBeVisible();
await expect(page.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" })).toBeHidden();
await expect(page.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeHidden();
await page.goto("/ai-characters/101/audio-contents/9001/edit");
await expect(page.getByText(/데스크톱.*(수정|업로드|비활성화|이용)/)).toBeVisible();
await expect(page.getByRole("form", { name: "오디오 콘텐츠 수정 입력 화면" })).toBeHidden();
await expectActionAbsentAcrossInteractiveRoles(page, "비활성화");
await expect(page.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeHidden();
});
test("desktop and tablet expose audio mutation and upload actions", async ({ page }) => {
// Given
await loginThroughMockMode(page);
for (const width of [768, 1280]) {
await page.setViewportSize({ width, height: 900 });
// When / Then
await openAudioList(page);
await expect(page.getByRole("button", { name: "오디오 생성" })).toBeVisible();
await page.goto("/ai-characters/101/audio-contents/9001");
await expect(page.getByRole("button", { name: "수정", exact: true })).toBeVisible();
await page.goto("/ai-characters/101/audio-contents/new");
await expect(page.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" })).toBeVisible();
await expect(page.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeVisible();
await page.goto("/ai-characters/101/audio-contents/9001/edit");
await expect(page.getByRole("form", { name: "오디오 콘텐츠 수정 입력 화면" })).toBeVisible();
await expect(page.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeVisible();
await expect(page.getByRole("button", { name: "비활성화" })).toBeVisible();
}
});
test("desktop create form exposes P4-R10 native controls and conditional states without overflow", async ({ page }) => {
// Given
let createRequests = 0;
page.on("request", (request) => {
const requestUrl = new URL(request.url());
if (request.method() === "POST" && requestUrl.pathname === "/api/v2/admin/ai-characters/101/audio-contents") {
createRequests += 1;
}
});
await page.setViewportSize({ width: 1280, height: 900 });
await loginThroughMockMode(page);
// When
await page.goto("/ai-characters/101/audio-contents/new");
// Then: free state
await expect(page.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" })).toBeVisible();
await expect(page.getByRole("group", { name: "구매 옵션" })).toHaveCount(0);
await expect(page.getByLabel("미리듣기 생성")).toHaveCount(0);
await expect(page.getByLabel("포인트 사용")).toHaveCount(0);
await expect(page.getByLabel("미리듣기 시작")).toHaveCount(0);
await expect(page.getByLabel("미리듣기 종료")).toHaveCount(0);
await expect(page.getByLabel("예약 공개일")).toHaveCount(0);
const priceInput = page.getByLabel("가격", { exact: true });
await expect(priceInput).toHaveAttribute("type", "number");
await expect(priceInput).toHaveAttribute("min", "0");
await expect(priceInput).toHaveAttribute("step", "1");
await expect(priceInput).toHaveValue("0");
await priceInput.focus();
await page.keyboard.press("ArrowDown");
await expect(priceInput).toHaveValue("0");
await page.keyboard.press("ArrowUp");
await expect(priceInput).toHaveValue("1");
await priceInput.fill("0");
await expectNoHorizontalOverflow(page);
// When: tags commit through both keyboard separators
const tagInput = page.getByLabel("태그", { exact: true });
await tagInput.fill("상담");
await tagInput.press("Enter");
await tagInput.fill("힐링");
await tagInput.press(",");
// Then
await expect(tagInput).toHaveValue("");
await expect(page.getByRole("button", { name: "태그 상담 삭제" })).toBeVisible();
await expect(page.getByRole("button", { name: "태그 힐링 삭제" })).toBeVisible();
expect(createRequests).toBe(0);
// When
await page.getByRole("button", { name: "태그 상담 삭제" }).click();
// Then
await expect(page.getByRole("button", { name: "태그 상담 삭제" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "태그 힐링 삭제" })).toBeVisible();
// When: a positive normalized price enables paid controls
await priceInput.fill("1000");
// Then: paid state
await expect(priceInput).toHaveValue("1000");
const purchaseOptions = page.getByRole("group", { name: "구매 옵션" });
await expect(purchaseOptions).toBeVisible();
const bothRadio = page.getByRole("radio", { name: "구매/대여" });
const buyOnlyRadio = page.getByRole("radio", { name: "구매 전용" });
const rentOnlyRadio = page.getByRole("radio", { name: "대여 전용" });
await expect(bothRadio).toHaveAttribute("type", "radio");
await expect(buyOnlyRadio).toHaveAttribute("type", "radio");
await expect(rentOnlyRadio).toHaveAttribute("type", "radio");
await expect(bothRadio).toBeChecked();
await rentOnlyRadio.focus();
await page.keyboard.press("Space");
await expect(rentOnlyRadio).toBeChecked();
await expect(page.getByLabel("기간제")).toHaveCount(0);
await expect(page.getByRole("checkbox", { name: "대여 전용" })).toHaveCount(0);
await expect(page.getByLabel("언어 코드")).toHaveCount(0);
await expect(page.getByLabel("성인 콘텐츠")).toBeVisible();
await expect(page.getByLabel("포인트 사용")).toBeVisible();
await expect(page.getByLabel("댓글 허용")).toBeVisible();
await expect(page.getByLabel("상세 정보 전체 공개")).toBeVisible();
await expectNoHorizontalOverflow(page);
// When: preview is enabled
const previewCheckbox = page.getByLabel("미리듣기 생성");
await previewCheckbox.check();
// Then: preview duration offsets
const previewStart = page.getByLabel("미리듣기 시작");
const previewEnd = page.getByLabel("미리듣기 종료");
await expect(previewStart).toHaveAttribute("type", "text");
await expect(previewStart).toHaveAttribute("placeholder", "예: 00:00:30");
await expect(previewStart).toHaveAttribute("pattern", "[0-9]{2}:[0-9]{2}:[0-9]{2}");
await expect(previewStart).not.toHaveAttribute("step");
await expect(previewEnd).toHaveAttribute("type", "text");
await expect(previewEnd).toHaveAttribute("placeholder", "예: 01:00:05");
await expect(previewEnd).toHaveAttribute("pattern", "[0-9]{2}:[0-9]{2}:[0-9]{2}");
await expect(previewEnd).not.toHaveAttribute("step");
await expectNoHorizontalOverflow(page);
// When / Then: unchecking preview clears the visible time controls
await previewCheckbox.uncheck();
await expect(previewStart).toHaveCount(0);
await expect(previewEnd).toHaveCount(0);
// When: preview state is populated before returning to free
await previewCheckbox.check();
await page.getByLabel("포인트 사용").check();
await previewStart.fill("00:00:30");
await previewEnd.fill("01:00:05");
// Then: immediate mode has no datetime
await expect(page.getByLabel("즉시 공개")).toBeChecked();
await expect(page.getByLabel("예약 공개일")).toHaveCount(0);
// When: scheduled mode is selected
await page.getByLabel("예약 공개").check();
// Then: exactly one native scheduled datetime is rendered
const releaseDateTime = page.getByLabel("예약 공개일");
await expect(releaseDateTime).toHaveCount(1);
await expect(releaseDateTime).toHaveAttribute("type", "datetime-local");
await releaseDateTime.fill("2026-08-05T12:00");
await expectNoHorizontalOverflow(page);
// When / Then: immediate mode removes and clears the scheduled datetime
await page.getByLabel("즉시 공개").check();
await expect(releaseDateTime).toHaveCount(0);
await page.getByLabel("예약 공개").check();
await expect(page.getByLabel("예약 공개일")).toHaveValue("");
await page.getByLabel("즉시 공개").check();
// When: returning to zero resets and hides paid state
await priceInput.fill("0");
// Then
await expect(purchaseOptions).toHaveCount(0);
await expect(previewCheckbox).toHaveCount(0);
await expect(page.getByLabel("포인트 사용")).toHaveCount(0);
await expect(previewStart).toHaveCount(0);
await expect(previewEnd).toHaveCount(0);
await expectNoHorizontalOverflow(page);
// When: paid controls return
await priceInput.fill("1");
// Then: their reset defaults are visible
await expect(page.getByRole("radio", { name: "구매/대여" })).toBeChecked();
await expect(page.getByRole("radio", { name: "대여 전용" })).not.toBeChecked();
await expect(page.getByLabel("미리듣기 생성")).not.toBeChecked();
await expect(page.getByLabel("포인트 사용")).not.toBeChecked();
await expect(page.getByLabel("미리듣기 시작")).toHaveCount(0);
await expect(page.getByLabel("미리듣기 종료")).toHaveCount(0);
// When: the preserved max boundary is exceeded
await priceInput.fill("100000");
await page.getByRole("button", { name: "생성" }).click();
// Then
await expect(priceInput).toHaveValue("100000");
await expect(page.getByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeVisible();
expect(createRequests).toBe(0);
});
test("audio list detail player error and long title avoid 320px overflow at 200 percent zoom", async ({ page }) => {
// Given
await page.setViewportSize({ width: 320, height: 640 });
await loginThroughMockMode(page);
for (const route of ["/ai-characters/101/audio-contents", "/ai-characters/101/audio-contents/9001"] as const) {
await page.goto(route);
await expect(page.getByRole("group", { name: "달빛 상담 오디오 오디오 플레이어" })).toBeVisible();
// When
await page.evaluate(() => {
const title = document.querySelector("#audio-content-title") ?? document.querySelector("[aria-label='달빛 상담 오디오 상세 보기'] span span");
if (title !== null) {
title.textContent = "공백없이매우긴오디오콘텐츠제목이운영화면에서잘리지않고읽히는지확인합니다";
}
document.querySelector("audio")?.dispatchEvent(new Event("error"));
document.documentElement.style.zoom = "2";
});
// Then
await expect(page.getByRole("alert")).toContainText("오디오를 재생할 수 없습니다.");
await expectNoHorizontalOverflow(page);
}
});
test("keyboard only reaches audio player and desktop form actions without double toggling descendants", async ({ browserName, isMobile, page }) => {
test.skip(isMobile, "Mobile view intentionally blocks Audio mutation routes.");
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 openAudioList(page);
const player = page.getByRole("group", { name: "달빛 상담 오디오 오디오 플레이어" }).first();
// When / Then
await pressTabUntilFocused(page, player);
await page.keyboard.press("Space");
await expect(player.getByRole("button", { name: "일시정지" })).toBeVisible();
await pressTabUntilFocused(page, player.getByRole("button", { name: "일시정지" }));
await page.keyboard.press("Enter");
await expect(player.getByRole("button", { name: "재생" })).toBeVisible();
await page.goto("/ai-characters/101/audio-contents/new");
await pressTabUntilFocused(page, page.getByLabel("제목"));
await page.keyboard.type("키보드 오디오");
await pressTabUntilFocused(page, page.getByLabel("오디오 테마"));
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await pressTabUntilFocused(page, page.getByRole("button", { name: "오디오 파일 파일 선택" }));
await pressTabUntilFocused(page, page.getByRole("button", { name: "생성" }));
});
test("audio states have no critical or serious axe violations at 320 768 and 1280px with 200 percent zoom", async ({ page }) => {
// Given
await loginThroughMockMode(page);
for (const width of [320, 768, 1280]) {
for (const route of ["/ai-characters/101/audio-contents", "/ai-characters/101/audio-contents/9001"] as const) {
await page.setViewportSize({ width, height: 900 });
await page.goto(route);
await zoomTo200Percent(page);
await expectNoHorizontalOverflow(page);
await expectNoCriticalOrSeriousAxeViolations(page);
}
await page.setViewportSize({ width, height: 900 });
await page.goto("/ai-characters/101/audio-contents/new");
await zoomTo200Percent(page);
await expectNoHorizontalOverflow(page);
await expectNoCriticalOrSeriousAxeViolations(page);
}
});