195 lines
7.5 KiB
TypeScript
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,
|
|
});
|
|
});
|