import { expect, test } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; import { ENVIRONMENTS, WORDS } from "../../src/content"; import { isAudioRequest } from "./audio-requests"; // 실제 소스 시작/정지를 관찰하되 브라우저의 원래 Web Audio 메서드는 그대로 실행한다. // 테스트가 ended를 조작하거나 가짜 음원을 주입하면 실제 재생 검증이 되지 않는다. declare global { interface Window { audioProbe: { starts: { loop: boolean; duration: number }[]; stops: number; }; } } test.beforeEach(async ({ page }) => { await page.addInitScript(() => { window.audioProbe = { starts: [], stops: 0 }; const start = AudioBufferSourceNode.prototype.start; const stop = AudioBufferSourceNode.prototype.stop; AudioBufferSourceNode.prototype.start = function (...args) { window.audioProbe.starts.push({ loop: this.loop, duration: this.buffer?.duration ?? 0, }); return start.apply(this, args); }; AudioBufferSourceNode.prototype.stop = function (...args) { window.audioProbe.stops++; return stop.apply(this, args); }; }); }); test("시작 단계·SNR·3문항 완료→다음 단계·재생 중 종료·응답 후 종료·홈 초기화", async ({ page, }, info) => { const errors: string[] = [], failed: string[] = [], external: string[] = []; let target = ""; page.on("pageerror", (e) => errors.push(e.message)); page.on("console", (msg) => { if (msg.type() === "error") errors.push(msg.text()); }); page.on("requestfailed", (r) => failed.push(r.url())); page.on("request", (r) => { const url = new URL(r.url()); if (url.origin !== "http://127.0.0.1:4173") external.push(url.href); if (isAudioRequest(url) && url.pathname !== "/audio/intro.wav") target = url.pathname; }); await page.goto("/"); await page.getByRole("radio", { name: "보기에서 선택" }).check(); await page.getByLabel("시작 난이도").selectOption("4"); const snr = page.getByRole("slider", { name: "목표 음성 SNR" }); await expect(snr).toHaveValue("-3"); await snr.fill("-4"); await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click(); for (let index = 0; index < 3; index++) { if (index) await page.getByRole("button", { name: /문장 듣기/ }).click(); await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({ timeout: 30000, }); await expect(page.getByText("4단계 · SNR -4 dB")).toBeVisible(); const answer = WORDS.find((word) => target.endsWith(`-${word.id}.wav`), )!.answer; await page.getByRole("button", { name: new RegExp(answer) }).click(); await page .getByRole("button", { name: index === 2 ? /결과 보기/ : /다음 문제/ }) .click(); } await expect(page.getByText("응답 3문항 · 정답 3문항")).toBeVisible(); await page.getByRole("button", { name: "다음 난이도 · 5단계" }).click(); await expect(page.getByText("5단계 · SNR -4 dB")).toBeVisible(); await expect .poll(() => page.evaluate(() => window.audioProbe.starts.length)) .toBe(10); // 예약 시작보다 조금 뒤 종료하여 fetch 취소뿐 아니라 실제 loop 정지를 검증한다. await page.waitForTimeout(250); await page.getByRole("button", { name: "문제 풀이 종료" }).click(); await expect(page.getByText("응답 0문항 · 정답 0문항")).toBeVisible(); expect(await page.evaluate(() => window.audioProbe.stops)).toBe(10); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: info.outputPath("early-exit.png"), fullPage: true, }); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); await page.getByRole("button", { name: "처음 화면으로" }).click(); await expect(snr).toHaveValue("-3"); await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click(); await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({ timeout: 30000, }); await snr.fill("0"); await page.getByRole("button", { name: /다시 듣기/ }).click(); await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible(); await expect(page.getByText("4단계 · SNR 0 dB")).toBeVisible(); await page .getByRole("button", { name: new RegExp( WORDS.find((w) => target.endsWith(`-${w.id}.wav`))!.answer, ), }) .click(); await page.getByRole("button", { name: "문제 풀이 종료" }).click(); await expect(page.getByText("응답 1문항 · 정답 1문항")).toBeVisible(); await expect( page.getByRole("cell", { name: "SNR 0 dB" }).first(), ).toBeVisible(); await page.getByRole("button", { name: "처음 화면으로" }).click(); await expect(snr).toHaveValue("-3"); expect({ errors, failed, external }).toEqual({ errors: [], failed: [], external: [], }); await info.attach("runtime-audit", { body: JSON.stringify( { errors, failed, external, audio: await page.evaluate(() => window.audioProbe), }, null, 2, ), contentType: "application/json", }); }); test("여덟 환경의 실제 WAV를 각각 디코드하고 루프 재생한 뒤 즉시 정지한다", async ({ page, }) => { const requests: string[] = [], errors: string[] = [], failed: string[] = [], external: string[] = []; page.on("request", (r) => { const url = new URL(r.url()); if (url.pathname.startsWith("/ambience/")) requests.push(url.pathname); if (url.origin !== "http://127.0.0.1:4173") external.push(url.href); }); page.on("pageerror", (e) => errors.push(e.message)); page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); page.on("requestfailed", (r) => failed.push(r.url())); await page.goto("/"); for (const [index, environment] of ENVIRONMENTS.entries()) { await page.getByLabel("배경 환경").selectOption(environment.id); await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click(); await expect .poll(() => page.evaluate(() => window.audioProbe.starts.length)) .toBe((index + 1) * 3); await page.waitForTimeout(250); await page.getByRole("button", { name: "문제 풀이 종료" }).click(); await expect(page.getByText("응답 0문항 · 정답 0문항")).toBeVisible(); await page.getByRole("button", { name: "처음 화면으로" }).click(); } const probe = await page.evaluate(() => window.audioProbe); expect(probe.starts.filter((s) => s.loop).map((s) => s.duration)).toEqual( Array(8).fill(12), ); expect(probe.stops).toBe(24); expect(requests).toEqual(ENVIRONMENTS.map((e) => `/ambience/${e.id}.wav`)); expect({ errors, failed, external }).toEqual({ errors: [], failed: [], external: [], }); }); // 장애 주입은 정상 흐름의 네트워크 무결성 집계와 분리하고 재시도 성공을 확인한다. test("환경음 404는 한국어 오류와 재시도 후 정상 답변으로 복구한다", async ({ page, }) => { await page.route(/\/ambience\/.*\.wav/, (route) => route.fulfill({ status: 404, body: "missing" }), ); await page.goto("/"); await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click(); await expect(page.getByRole("alert")).toContainText( "소리를 재생하지 못했어요", ); await page.unroute(/\/ambience\/.*\.wav/); await page.getByRole("button", { name: "다시 시도" }).click(); await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({ timeout: 30000, }); });