test(ai-character): 리소스 운영 E2E 보강
This commit is contained in:
321
tests/e2e/series.spec.ts
Normal file
321
tests/e2e/series.spec.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
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<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 expectNoRawSeriesDisplayText(page: Page): Promise<void> {
|
||||
await expect(page.getByText(/SUN|PROCEEDING|isAdult true|isActive true|genreId/)).toBeHidden();
|
||||
}
|
||||
|
||||
async function expectActionAbsentAcrossInteractiveRoles(page: Page, name: string): Promise<void> {
|
||||
for (const role of interactiveActionRoles) {
|
||||
await expect(page.getByRole(role, { name })).toBeHidden();
|
||||
}
|
||||
}
|
||||
|
||||
async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> {
|
||||
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<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 setSeriesImageFile(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
await page.goto("/ai-characters/101/series/5001");
|
||||
await expect(page.getByRole("heading", { name: "달빛 상담 시리즈", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function linkMorningAudio(page: Page): Promise<void> {
|
||||
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("새로운 시리즈 소개");
|
||||
await page.getByLabel("키워드").fill("달빛");
|
||||
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("아침 루틴 시리즈");
|
||||
});
|
||||
Reference in New Issue
Block a user