import { expect, test } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; import { WORDS } from "../../src/content"; import { isAudioRequest } from "./audio-requests"; // 실제 WAV를 디코드하고 Web Audio의 ended를 기다린다. 모의 엔진을 사용한 // 컴포넌트 검증과 달리 배포 산출물·자동 재생 정책·레이아웃을 함께 확인한다. test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음", async ({ page, }, info) => { const external: string[] = []; const errors: string[] = []; let audioPath = ""; const audioUrls: URL[] = []; page.on("request", (request) => { const url = new URL(request.url()); if (url.origin !== "http://127.0.0.1:4173") external.push(request.url()); if (isAudioRequest(url)) { audioUrls.push(url); if (url.pathname !== "/audio/intro.wav") audioPath = url.pathname; } }); page.on("pageerror", (error) => errors.push(error.message)); await page.goto("/"); await expect( page.getByRole("heading", { name: "단어를 맞춰라" }), ).toBeVisible(); expect(audioPath).toBe(""); expect( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ).toBe(true); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: info.outputPath("home.png"), fullPage: true, }); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); await page.emulateMedia({ reducedMotion: "reduce" }); await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click(); await expect.poll(() => audioUrls.length).toBe(2); expect(audioUrls.map((url) => url.searchParams.get("v"))).toEqual([ "0.2.5", "0.2.5", ]); await expect(page.getByRole("button", { name: /듣는 중/ })).toBeDisabled(); await expect(page.locator(".sound-wave.active i").first()).toHaveCSS( "animation-name", "none", ); await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible(); for (let i = 0; i < 3; i++) { if (i) await page.getByRole("button", { name: /문장 듣기/ }).click(); const target = i % 2 ? page.getByRole("textbox", { name: "들은 단어" }) : page.getByRole("group", { name: "답 선택" }); await expect(target).toBeVisible(); // 세 문항 모두 실제 렌더링을 거쳐 어절 보존과 패널 내 표시를 검증한다. const heading = page.locator(".question-panel h2"); await expect(heading).toHaveCSS("word-break", "keep-all"); expect( await heading.evaluate( (element) => element.scrollWidth <= element.clientWidth, ), ).toBe(true); const answer = WORDS.find((w) => audioPath.endsWith(`-${w.id}.wav`), )!.answer; if (i === 0) { expect( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ).toBe(true); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: info.outputPath("game.png"), fullPage: true, }); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); await page.getByRole("button", { name: /다시 듣기/ }).click(); await expect(target).toBeVisible(); } if (i % 2) { await target.fill(answer); await target.press("Enter"); } else { const button = page.getByRole("button", { name: new RegExp(answer), }); await button.focus(); await button.press("Enter"); } await expect( page.getByRole("heading", { name: "정답이에요!" }), ).toBeVisible(); await page .getByRole("button", { name: i === 2 ? /결과 보기/ : /다음 문제/ }) .click(); } await expect(page.getByText("100%")).toBeVisible(); expect( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ).toBe(true); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: info.outputPath("results.png"), fullPage: true, }); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); await page.getByRole("button", { name: "처음 화면으로" }).click(); await expect( page.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ).toBeVisible(); expect(new Set(audioUrls.map((url) => url.searchParams.get("v")))).toEqual( new Set(["0.2.5"]), ); expect( audioUrls.filter((url) => url.pathname !== "/audio/intro.wav"), ).toHaveLength(4); expect(external).toEqual([]); expect(errors).toEqual([]); }); for (const failed of ["intro", "target"]) { test(`${failed} WAV query URL의 404를 표시하고 같은 URL로 재시도·정답 mapping한다`, async ({ page, }) => { const audioUrls: string[] = []; const missing: string[] = []; page.on("request", (request) => { if (isAudioRequest(new URL(request.url()))) audioUrls.push(request.url()); }); const matchesFailure = (url: URL) => isAudioRequest(url) && (failed === "intro" ? url.pathname === "/audio/intro.wav" : url.pathname !== "/audio/intro.wav"); await page.route(matchesFailure, (route) => { missing.push(route.request().url()); return route.fulfill({ status: 404, body: "missing" }); }); await page.goto("/"); await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click(); await expect(page.getByRole("alert")).toContainText( "소리를 재생하지 못했어요", ); expect(missing).toHaveLength(1); expect(new URL(missing[0]!).searchParams.get("v")).toBe("0.2.5"); expect(audioUrls).toHaveLength(2); await page.unroute(matchesFailure); await page.getByRole("button", { name: "다시 시도" }).click(); // 안내 7.056 + 최대 목표 11.256초와 로드·디코드 여유를 포함한다. await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({ timeout: 30_000, }); expect(audioUrls).toHaveLength(4); expect(audioUrls.slice(2).sort()).toEqual(audioUrls.slice(0, 2).sort()); expect( new Set(audioUrls.map((url) => new URL(url).searchParams.get("v"))), ).toEqual(new Set(["0.2.5"])); const targetPath = audioUrls .map((url) => new URL(url).pathname) .find((path) => path !== "/audio/intro.wav")!; const answer = WORDS.find((word) => targetPath.endsWith(`-${word.id}.wav`), )!.answer; await page.getByRole("button", { name: new RegExp(answer) }).click(); await expect( page.getByRole("heading", { name: "정답이에요!" }), ).toBeVisible(); await page.getByRole("button", { name: "문제 풀이 종료" }).click(); await page.getByRole("button", { name: "처음 화면으로" }).click(); await expect( page.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ).toBeFocused(); }); }