Files
2026-10-14 10:01:46 +00:00

195 lines
7.5 KiB
TypeScript

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,
});
});