feat: release Find-Word v0.1

This commit is contained in:
oz
2026-10-14 06:19:24 +00:00
parent 28ee2ce498
commit 1c0069c367
258 changed files with 21509 additions and 0 deletions
+336
View File
@@ -0,0 +1,336 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import App from "../src/App";
import type { AudioPort } from "../src/audio/engine";
import { createQuestions } from "../src/game";
// 사용자가 보는 의미 역할로만 화면을 조작한다. 오디오 완료를 수동 제어하여
// 재생 중에는 문제·선택지가 노출되지 않는지, 오류 뒤 재시도가 가능한지 본다.
function fixture() {
let resolve!: () => void;
let reject!: (reason: Error) => void;
const audio: AudioPort = {
play: vi.fn((_q, _intro, started) => {
started();
return new Promise<void>((yes, no) => {
resolve = yes;
reject = no;
});
}),
stop: vi.fn(),
dispose: vi.fn(),
setVolume: vi.fn(),
};
const user = userEvent.setup();
const view = render(<App audio={audio} seed={42} />);
return {
audio,
user,
...view,
finish: async () => {
await act(async () => resolve());
},
fail: async () => {
await act(async () => reject(new Error("파일 로드 실패")));
},
};
}
describe("시작부터 결과까지 사용자 흐름", () => {
it("시작 안내는 장식 번호 대신 8가지 환경을 표시한다", () => {
fixture();
expect(screen.getByText("8가지 환경", { exact: true })).toBeInTheDocument();
expect(screen.queryByText("01 / 08")).not.toBeInTheDocument();
});
it("시작 안내의 현재 페이지 링크는 아래 화살표를 표시한다", () => {
fixture();
const link = screen.getByRole("link", { name: "플레이 방법" });
expect(link).toHaveAttribute("href", "#howto");
expect(link).toHaveTextContent("플레이 방법 ↓");
expect(within(link).getByText("↓")).toHaveAttribute("aria-hidden", "true");
});
it("시작 안내의 CTA는 게임 시작과 소리 재생을 함께 알린다", async () => {
const f = fixture();
const start = screen.getByRole("button", {
name: "게임 시작 · 소리 듣기",
});
expect(start).toHaveTextContent("게임 시작 · 소리 듣기");
expect(f.audio.play).not.toHaveBeenCalled();
await f.user.click(start);
expect(f.audio.play).toHaveBeenCalledTimes(1);
await f.finish();
});
it("전체 음량을 시작 CTA 앞 설정 카드와 게임 환경 패널에 배치하고 값을 유지한다", async () => {
const f = fixture();
const setup = screen.getByRole("region", {
name: "나의 소리 탐험 준비",
});
const volume = screen.getByRole("slider", { name: "전체 음량" });
const start = screen.getByRole("button", {
name: "게임 시작 · 소리 듣기",
});
expect.soft(setup).toContainElement(volume);
// CSS 배치만 바꾸지 않고 키보드·스크린리더도 음량→시작 순서로 읽어야 한다.
expect
.soft(
volume.compareDocumentPosition(start) &
Node.DOCUMENT_POSITION_FOLLOWING,
)
.not.toBe(0);
fireEvent.change(volume, { target: { value: "20" } });
await f.user.click(start);
const gameVolume = screen.getByRole("slider", { name: "전체 음량" });
expect.soft(screen.getByRole("complementary")).toContainElement(gameVolume);
expect(screen.getAllByRole("slider", { name: "전체 음량" })).toHaveLength(
1,
);
expect(gameVolume).toHaveValue("20");
fireEvent.change(gameVolume, { target: { value: "60" } });
expect(f.audio.setVolume).toHaveBeenLastCalledWith(0.6);
await f.finish();
await f.user.click(screen.getByRole("button", { name: "처음으로" }));
expect(screen.getByRole("slider", { name: "전체 음량" })).toHaveValue("60");
});
it("다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다", async () => {
const f = fixture();
const questions = createQuestions(42, "choice");
// 양수·0·음수를 각각 명시해 부호만 바꾸고 설명은 그대로인 회귀도 잡는다.
const levels = [
["SNR +6 dB", "음성이 배경보다 6 dB 커요."],
["SNR +3 dB", "음성이 배경보다 3 dB 커요."],
["SNR 0 dB", "음성과 배경의 크기가 같아요."],
["SNR -3 dB", "음성이 배경보다 3 dB 작아요."],
["SNR -6 dB", "음성이 배경보다 6 dB 작아요."],
] as const;
await f.user.click(screen.getByRole("radio", { name: "보기에서 선택" }));
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
for (const [index, question] of questions.entries()) {
if (index)
await f.user.click(screen.getByRole("button", { name: /문장 듣기/ }));
await f.finish();
const [label, description] = levels[Math.floor(index / 2)]!;
expect
.soft(screen.queryByText(`${question.level}단계 · ${label}`))
.toBeInTheDocument();
expect
.soft(screen.queryByText(description, { exact: false }))
.toBeInTheDocument();
await f.user.click(
screen.getByRole("button", {
name: new RegExp(question.word.answer),
}),
);
await f.user.click(
screen.getByRole("button", {
name: index === 9 ? /결과 보기/ : /다음 문제/,
}),
);
}
const table = within(screen.getByRole("table", { name: "난이도별 결과" }));
expect
.soft(table.queryByRole("columnheader", { name: "SNR" }))
.toBeInTheDocument();
for (const [label] of levels) {
expect
.soft(table.queryByRole("cell", { name: label }))
.toBeInTheDocument();
}
});
it("한글 조합 도중 처음으로 돌아가도 새 세션의 입력은 정상 제출된다", async () => {
const f = fixture();
await f.user.click(screen.getByRole("radio", { name: "직접 입력" }));
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.finish();
fireEvent.compositionStart(
screen.getByRole("textbox", { name: "들은 단어" }),
);
await f.user.click(screen.getByRole("button", { name: "처음으로" }));
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.finish();
fireEvent.compositionStart(
screen.getByRole("textbox", { name: "들은 단어" }),
);
await f.user.click(screen.getByRole("button", { name: /다시 듣기/ }));
await f.finish();
await f.user.type(
screen.getByRole("textbox", { name: "들은 단어" }),
createQuestions(43, "text")[0]!.word.answer,
);
await f.user.click(screen.getByRole("button", { name: "정답 확인" }));
expect(screen.getByText("정답이에요!")).toBeInTheDocument();
});
it("한글 조합을 확정하는 Enter는 채점하지 않고 다음 제출을 기다린다", async () => {
const f = fixture();
await f.user.click(screen.getByRole("radio", { name: "직접 입력" }));
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.finish();
const textbox = screen.getByRole("textbox", { name: "들은 단어" });
fireEvent.compositionStart(textbox);
fireEvent.change(textbox, {
target: { value: createQuestions(42, "text")[0]!.word.answer },
});
fireEvent.submit(textbox.closest("form")!);
expect(screen.queryByText("정답이에요!")).not.toBeInTheDocument();
fireEvent.compositionEnd(textbox);
fireEvent.submit(textbox.closest("form")!);
expect(screen.getByText("정답이에요!")).toBeInTheDocument();
});
it("브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다", () => {
const f = fixture();
expect(
screen.getByRole("heading", { name: "단어를 맞춰라" }),
).toBeInTheDocument();
expect(
screen.getByText("Designed by OZ at Heat-T Medical Inc.", {
exact: true,
}),
).toBeInTheDocument();
expect(screen.getByText("v0.1")).toBeInTheDocument();
expect(f.audio.play).not.toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
).toBeEnabled();
});
it("재생 동안 답을 숨기고 중복 클릭을 막으며 끝나면 네 선택지에 접근할 수 있다", async () => {
const f = fixture();
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
expect(f.audio.play).toHaveBeenCalledTimes(1);
expect(
screen.queryByRole("group", { name: "답 선택" }),
).not.toBeInTheDocument();
expect(
screen.queryByText(createQuestions(42, "mixed")[0]!.sentence),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /듣는 중/ })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: /듣는 중/ }));
expect(f.audio.play).toHaveBeenCalledTimes(1);
await f.finish();
const group = screen.getByRole("group", { name: "답 선택" });
expect(group.querySelectorAll("button")).toHaveLength(4);
expect(
screen.getByRole("heading", {
name: createQuestions(42, "mixed")[0]!.pattern.question,
}),
).toHaveFocus();
await f.user.click(screen.getByRole("button", { name: /다시 듣기/ }));
expect(f.audio.play).toHaveBeenCalledTimes(2);
await f.finish();
});
it("혼합 모드 열 문제를 키보드·텍스트로 풀고 결과에서 다시 시작한다", async () => {
const f = fixture();
const questions = createQuestions(42, "mixed");
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
for (let i = 0; i < questions.length; i++) {
if (i)
await f.user.click(screen.getByRole("button", { name: /문장 듣기/ }));
await f.finish();
const q = questions[i]!;
if (q.mode === "choice") {
screen.getByRole("button", { name: new RegExp(q.word.answer) }).focus();
await f.user.keyboard("{Enter}");
} else {
await f.user.type(
screen.getByRole("textbox", { name: "들은 단어" }),
` ${q.word.answer} `,
);
await f.user.keyboard("{Enter}");
}
expect(screen.getByText("정답이에요!")).toBeInTheDocument();
expect(screen.getByText(q.sentence)).toBeInTheDocument();
await f.user.click(
screen.getByRole("button", {
name: i === 9 ? /결과 보기/ : /다음 문제/,
}),
);
}
expect(
screen.getByRole("heading", { name: /오늘의 소리 탐험/ }),
).toBeInTheDocument();
expect(screen.getByText("100%")).toBeInTheDocument();
expect(
screen.getByRole("table", { name: "난이도별 결과" }),
).toBeInTheDocument();
await f.user.click(screen.getByRole("button", { name: "다시 시작" }));
expect(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
).toBeEnabled();
expect(f.audio.stop).toHaveBeenCalled();
});
it("오디오 오류를 알리고 재시도하며 나가면 장치를 정리한다", async () => {
const f = fixture();
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.fail();
expect(screen.getByRole("alert")).toHaveTextContent(
/소리를 재생하지 못했어요/,
);
await f.user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(f.audio.play).toHaveBeenCalledTimes(2);
await f.finish();
f.unmount();
expect(f.audio.dispose).toHaveBeenCalled();
});
it("선택한 주관식·배경 환경으로 시작하고 빈 답변을 제출하지 않는다", async () => {
const f = fixture();
await f.user.click(screen.getByRole("radio", { name: /직접 입력/ }));
await f.user.selectOptions(screen.getByLabelText("배경 환경"), "rain");
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.finish();
expect(f.audio.play).toHaveBeenCalledWith(
expect.objectContaining({
mode: "text",
environment: expect.objectContaining({ id: "rain" }),
}),
true,
expect.any(Function),
);
expect(screen.getByRole("button", { name: "정답 확인" })).toBeDisabled();
await f.user.type(
screen.getByRole("textbox", { name: "들은 단어" }),
"<img src=x>",
);
await f.user.click(screen.getByRole("button", { name: "정답 확인" }));
expect(screen.getByText("다음엔 찾을 수 있어요.")).toBeInTheDocument();
expect(document.querySelector("img")).toBeNull();
});
it("음량 조절이 엔진에 전달되고 세션 종료 뒤 늦은 완료는 무시한다", async () => {
const f = fixture();
fireEvent.change(screen.getByRole("slider", { name: "전체 음량" }), {
target: { value: "20" },
});
expect(f.audio.setVolume).toHaveBeenCalledWith(0.2);
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.user.click(screen.getByRole("button", { name: "처음으로" }));
await f.finish();
await waitFor(() =>
expect(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
).toBeEnabled(),
);
});
});
+72
View File
@@ -0,0 +1,72 @@
// @vitest-environment node
import { createHash } from "node:crypto";
import { readFileSync, readdirSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { INTRO, PATTERNS, WORDS } from "../src/content";
// 생성 바이너리를 눈으로 검토할 수 없으므로 문장·해시·PCM 포맷·실제 신호를
// 함께 확인한다. manifest는 생성기가 남기며 100개 조합과 안내를 빠짐없이 묶는다.
describe("저장한 한국어 음성 자산", () => {
it("실제 생성 도구 버전을 기록하여 재생성의 출처를 추적한다", () => {
const manifest = JSON.parse(
readFileSync("public/audio/manifest.json", "utf8"),
) as { toolVersions?: { espeak: string; ffmpeg: string } };
expect(manifest.toolVersions?.espeak).toMatch(/eSpeak NG.*\d+\.\d+/);
expect(manifest.toolVersions?.ffmpeg).toMatch(/ffmpeg version \d+\.\d+/);
});
it("모든 문장과 안내에 대응하는 로컬 WAV·출처·해시를 제공한다", () => {
const manifest = JSON.parse(
readFileSync("public/audio/manifest.json", "utf8"),
) as {
generator: string;
files: { file: string; text: string; sha256: string }[];
};
expect(manifest.generator).toContain("eSpeak NG");
const expected = [
{ file: "intro.wav", text: INTRO },
...PATTERNS.flatMap((p) =>
WORDS.map((w) => ({
file: `${p.id}-${w.id}.wav`,
text: p.sentence
.replaceAll("{word}", w.answer)
.replaceAll("{distractor1}", w.distractors[0]!.word)
.replaceAll("{distractor2}", w.distractors[1]!.word),
})),
),
];
expect(manifest.files.map(({ file, text }) => ({ file, text }))).toEqual(
expected,
);
expect(
readdirSync("public/audio").filter((f) => f.endsWith(".wav")),
).toHaveLength(101);
let size = 0;
for (const entry of manifest.files) {
const file = readFileSync(`public/audio/${entry.file}`);
size += file.length;
expect(createHash("sha256").update(file).digest("hex")).toBe(
entry.sha256,
);
expect(file.toString("ascii", 0, 4)).toBe("RIFF");
expect(file.toString("ascii", 8, 12)).toBe("WAVE");
expect(file.readUInt16LE(22)).toBe(1);
expect(file.readUInt32LE(24)).toBe(16000);
expect(file.readUInt16LE(34)).toBe(16);
expect(file.length).toBeGreaterThan(16000);
const values = Array.from({ length: (file.length - 44) / 2 }, (_, i) =>
file.readInt16LE(44 + i * 2),
);
const rms = Math.sqrt(
values.reduce((sum, x) => sum + x * x, 0) / values.length,
);
expect(rms).toBeGreaterThan(100);
// RMS 정규화 후 최고 난이도 아닌 가장 큰 +6dB에서도 클리핑 여유를 확인한다.
expect(
(values.reduce((peak, x) => Math.max(peak, Math.abs(x)), 0) / rms) *
0.03 *
2,
).toBeLessThan(0.8);
}
expect(size).toBeLessThan(20 * 1024 * 1024);
});
});
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it, vi } from "vitest";
import { AudioEngine } from "../src/audio/engine";
import { createQuestions } from "../src/game";
// 녹음 재생 성공만 흉내 내는 대신 Web Audio 그래프의 노드·예약 시간·정리
// 호출을 기록한다. HTTP와 장치 경계를 주입하여 경합을 결정론적으로 재현한다.
function fixture() {
const param = () => ({
value: 1,
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
setTargetAtTime: vi.fn(),
});
const node = () => ({ connect: vi.fn(), disconnect: vi.fn() });
const gains: ReturnType<typeof makeGain>[] = [];
function makeGain(): ReturnType<typeof node> & {
gain: ReturnType<typeof param>;
} {
const gain = { ...node(), gain: param() };
gains.push(gain);
return gain;
}
function buffer(length = 16000, rate = 16000) {
const data = Float32Array.from(
{ length },
(_, i) => 0.2 * Math.sin(i / 20),
);
return {
duration: length / rate,
length,
sampleRate: rate,
numberOfChannels: 1,
getChannelData: () => data,
copyToChannel: vi.fn(),
};
}
const sources: ReturnType<typeof makeSource>[] = [];
function makeSource(): ReturnType<typeof node> & {
buffer: AudioBuffer | null;
loop: boolean;
start: ReturnType<typeof vi.fn<(when?: number) => void>>;
stop: ReturnType<typeof vi.fn>;
onended: null | (() => void);
} {
const source = {
...node(),
buffer: null,
loop: false,
start: vi.fn(),
stop: vi.fn(),
onended: null as null | (() => void),
};
sources.push(source);
return source;
}
const context = {
currentTime: 2,
sampleRate: 16000,
state: "suspended",
destination: {},
resume: vi.fn(async () => {
context.state = "running";
}),
close: vi.fn(async () => {}),
createGain: makeGain,
createBufferSource: makeSource,
createBuffer: (_channels: number, length: number, rate: number) =>
buffer(length, rate),
decodeAudioData: vi.fn(async () => buffer()),
};
const fetcher = vi.fn<typeof fetch>(
async () =>
({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }) as Response,
);
const factory = vi.fn(() => context as unknown as AudioContext);
return {
engine: new AudioEngine(factory, fetcher),
context,
sources,
gains,
fetcher,
factory,
};
}
const question = createQuestions(42, "choice")[0]!;
const flush = async () => {
for (let i = 0; i < 12; i++) await Promise.resolve();
};
describe("오디오 재생 수명과 믹서", () => {
it("생성 시 무음이며 gesture의 play에서만 장치를 연다", async () => {
const f = fixture();
expect(f.factory).not.toHaveBeenCalled();
const started = vi.fn();
const done = f.engine.play(question, true, started);
expect(f.factory).toHaveBeenCalledOnce();
expect(f.context.resume).toHaveBeenCalledOnce();
await flush();
expect(started).toHaveBeenCalledOnce();
expect(f.sources).toHaveLength(3);
expect(f.fetcher.mock.calls.map((call) => call[0])).toEqual([
"/audio/intro.wav",
question.audioUrl,
]);
// master, 배경, 목표, 안내의 gain을 분리한다. 안내는 점차 소음에 묻힌다.
expect(f.gains[1]!.gain.setValueAtTime).toHaveBeenCalledWith(
1,
expect.any(Number),
);
expect(f.gains[2]!.gain.setValueAtTime).toHaveBeenCalledWith(
10 ** (question.db / 20),
expect.any(Number),
);
expect(f.gains[3]!.gain.linearRampToValueAtTime).toHaveBeenCalledWith(
0.12,
expect.any(Number),
);
expect(f.sources[2]!.start.mock.calls[0]![0]!).toBeGreaterThan(
f.sources[1]!.start.mock.calls[0]![0]!,
);
f.sources[2]!.onended?.();
await done;
expect(f.sources.every((s) => s.disconnect.mock.calls.length > 0)).toBe(
true,
);
});
it("동시 시작을 거부하고 정지하면 모든 노드와 대기 Promise를 취소한다", async () => {
const f = fixture();
const done = f.engine.play(question, false, vi.fn());
const cancelled = expect(done).rejects.toMatchObject({
name: "AbortError",
});
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
"재생 중",
);
await flush();
f.engine.stop();
await cancelled;
expect(f.sources.every((s) => s.stop.mock.calls.length > 0)).toBe(true);
expect(f.sources.every((s) => s.onended === null)).toBe(true);
f.engine.dispose();
expect(f.context.close).toHaveBeenCalledOnce();
});
it("로드 도중 취소한 이전 작업이 늦게 완료되어도 소리를 시작하지 않는다", async () => {
const f = fixture();
let release!: (value: Response) => void;
f.fetcher.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
const done = f.engine.play(question, false, vi.fn());
const cancelled = expect(done).rejects.toMatchObject({
name: "AbortError",
});
await flush();
f.engine.stop();
await cancelled;
release({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
} as Response);
await flush();
expect(f.sources).toHaveLength(0);
});
it("HTTP·디코드·장치 실패를 전달하고 다음 시도는 다시 로드한다", async () => {
const f = fixture();
f.fetcher.mockResolvedValueOnce({ ok: false } as Response);
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
"음성 파일",
);
f.context.decodeAudioData.mockRejectedValueOnce(new Error("decode"));
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
"decode",
);
f.context.resume.mockRejectedValueOnce(new Error("권한"));
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
"권한",
);
const done = f.engine.play(question, false, vi.fn());
await flush();
f.sources.at(-1)!.onended?.();
await done;
});
it("전체 음량은 두 신호의 비율을 보존하며 0부터 1 범위로 제한한다", async () => {
const f = fixture();
f.engine.setVolume(0.3);
const done = f.engine.play(question, false, vi.fn());
await flush();
expect(f.gains[0]!.gain.value).toBe(0.3);
f.engine.setVolume(8);
expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(1, 2, 0.02);
f.engine.setVolume(-1);
expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(0, 2, 0.02);
f.sources.at(-1)!.onended?.();
await done;
});
});
+149
View File
@@ -0,0 +1,149 @@
import { expect, test } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
import { WORDS } from "../../src/content";
// 실제 WAV를 디코드하고 Web Audio의 ended를 기다린다. 모의 엔진을 사용한
// 컴포넌트 검증과 달리 배포 산출물·자동 재생 정책·레이아웃을 함께 확인한다.
test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음", async ({
page,
}, info) => {
const external: string[] = [];
const errors: string[] = [];
let audioPath = "";
page.on("request", (request) => {
if (!request.url().startsWith("http://127.0.0.1:4173"))
external.push(request.url());
if (
request.url().endsWith(".wav") &&
!request.url().endsWith("intro.wav")
)
audioPath = new URL(request.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: `.agent/evidence/release-${info.project.name}-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(
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 < 10; 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: `.agent/evidence/release-${info.project.name}-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 === 9 ? /결과 보기/ : /다음 문제/ })
.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: `.agent/evidence/release-${info.project.name}-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(external).toEqual([]);
expect(errors).toEqual([]);
});
test("오디오 HTTP 실패를 화면에 표시하고 재시도한다", async ({ page }) => {
await page.route("**/audio/*.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("**/audio/*.wav");
await page.getByRole("button", { name: "다시 시도" }).click();
// 재시도는 안내부터 재생한다. 최대 8.1205 + 10.1725 + 시작·전환 0.4초에
// 로드·디코드·병렬 브라우저 스케줄링 여유를 더해 이 assertion만 30초로 제한한다.
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "처음으로" }).click();
await expect(
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
).toBeFocused();
});
+21
View File
@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
// 소스의 사본만 확인하면 public 파일이 배포에서 빠진 경우를 놓친다.
// 실제 production preview가 원문 크레딧과 notice 전문을 제공하는지 확인한다.
test("릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공한다", async ({
page,
request,
}) => {
await page.goto("/");
await expect
.soft(
page.getByText("Designed by OZ at Heat-T Medical Inc.", { exact: true }),
)
.toBeVisible({ timeout: 1000 });
const response = await request.get("/THIRD_PARTY_NOTICES.md");
expect(response.ok()).toBe(true);
expect(await response.text()).toBe(
readFileSync("THIRD_PARTY_NOTICES.md", "utf8"),
);
});
+130
View File
@@ -0,0 +1,130 @@
import { expect, test, type Locator } from "@playwright/test";
import { createQuestions } from "../../src/game";
test("전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다", async ({
page,
}) => {
await page.goto("/");
const volume = page.getByRole("slider", { name: "전체 음량" });
// 문서상 소속뿐 아니라 컨트롤 전체가 카드 경계 안에 그려지는지 검증한다.
const expectInside = async (panel: Locator) => {
const bounds = (await panel.boundingBox())!;
const control = (await page.locator(".volume-row").boundingBox())!;
expect.soft(control.x).toBeGreaterThanOrEqual(bounds.x);
expect.soft(control.y).toBeGreaterThanOrEqual(bounds.y);
expect
.soft(control.x + control.width)
.toBeLessThanOrEqual(bounds.x + bounds.width);
expect
.soft(control.y + control.height)
.toBeLessThanOrEqual(bounds.y + bounds.height);
};
await expectInside(
page.getByRole("region", { name: "나의 소리 탐험 준비" }),
);
const start = page.getByRole("button", {
name: "게임 시작 · 소리 듣기",
});
const control = (await page.locator(".volume-row").boundingBox())!;
expect
.soft(control.y + control.height)
.toBeLessThanOrEqual((await start.boundingBox())!.y);
await volume.focus();
await volume.press("ArrowRight");
await expect(volume).toHaveValue("36");
await volume.press("Tab");
await expect(start).toBeFocused();
await start.click();
await expectInside(page.getByRole("complementary"));
await expect(volume).toHaveValue("36");
await volume.focus();
await volume.press("ArrowLeft");
await expect(volume).toHaveValue("35");
// 음량을 패널로 옮겨도 카드와 바닥글이 맞붙지 않도록 기존 화면 구분을 유지한다.
const cards = (await page.locator(".game-columns").boundingBox())!;
const footer = (await page.getByRole("contentinfo").boundingBox())!;
expect(footer.y - (cards.y + cards.height)).toBeGreaterThanOrEqual(24);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
});
test("난이도 설명은 모바일에서도 보인다", async ({ page }) => {
await page.goto("/");
await page
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
.click();
// DOM에만 설명을 남기고 모바일 CSS로 숨기면 컴포넌트 검증은 통과하므로
// 실제 화면에서 난이도 배지와 쉬운 설명을 모두 읽을 수 있는지 확인한다.
await expect
.soft(page.locator(".db-note"))
.toBeVisible({ timeout: 1000 });
await expect
.soft(page.getByText("1단계 · SNR +6 dB"))
.toBeVisible({ timeout: 1000 });
await expect
.soft(page.locator(".db-note"))
.toContainText("음성이 배경보다 6 dB 커요.", { timeout: 1000 });
});
// jsdom은 줄바꿈을 계산하지 않는다. 실제 Chrome에서 한국어 제목의
// computed style을 확인하고 360px에서도 질문과 페이지가 넘치지 않게 한다.
test("한국어 제목은 단어 중간에서 줄바꿈하지 않는다", async ({
page,
}, info) => {
await page.addInitScript(() => {
Date.now = () => 42;
});
await page.goto("/");
const wordBreaks = () =>
page.getByRole("heading").evaluateAll((headings) =>
headings.map((heading) => ({
title: heading.textContent,
wordBreak: getComputedStyle(heading).wordBreak,
})),
);
const homeHeadings = await wordBreaks();
expect
.soft(homeHeadings)
.toEqual(
homeHeadings.map((heading) => ({
...heading,
wordBreak: "keep-all",
})),
);
await page
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
.click();
const question = page.getByRole("heading", {
name: createQuestions(42, "mixed")[0]!.pattern.question,
});
await expect(question).toBeVisible();
// 전체 화면 캡처 전에 스크롤을 맞춰 화면 밖의 fixed 건너뛰기 링크가
// 이미지 안으로 들어오는 Chrome 캡처 아티팩트를 방지한다. 초점은 유지한다.
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({
path: `.agent/evidence/release-${info.project.name}-heading.png`,
fullPage: true,
});
const gameHeadings = await wordBreaks();
expect
.soft(gameHeadings)
.toEqual(
gameHeadings.map((heading) => ({
...heading,
wordBreak: "keep-all",
})),
);
expect(
await question.evaluate(
(heading) => heading.scrollWidth <= heading.clientWidth,
),
).toBe(true);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
});
+62
View File
@@ -0,0 +1,62 @@
// @vitest-environment node
import { execFileSync } from "node:child_process";
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
// 실제 배포는 하지 않는다. worktree 내부 임시 폴더에 public 자산만 복사한 뒤
// 정상·누락·변조를 검사하여 빌드 검증기가 실패를 정말 감지하는지 먼저 확인한다.
describe("production 자산 검증기", () => {
it("101개 음성 포함을 확인하고 누락·변조는 실패시킨다", () => {
mkdirSync(".cache", { recursive: true });
const temporary = mkdtempSync(".cache/build-test-");
const verify = () =>
execFileSync(
"node",
[
"--input-type=module",
"-e",
`import {verifyBuild} from './scripts/check-build.mjs'; console.log(verifyBuild(${JSON.stringify(temporary)}));`,
],
{ encoding: "utf8", stdio: "pipe" },
);
try {
cpSync("public", temporary, { recursive: true });
expect(verify().trim()).toBe("101");
writeFileSync(join(temporary, "audio/intro.wav"), "broken");
expect(verify).toThrow();
rmSync(join(temporary, "audio/intro.wav"));
expect(verify).toThrow();
} finally {
rmSync(temporary, { recursive: true, force: true });
}
});
it.each(["누락", "축약"])(
"dist의 런타임 notice %s을 실패시킨다",
(damage) => {
mkdirSync(".cache", { recursive: true });
const temporary = mkdtempSync(".cache/notice-test-");
const verify = () =>
execFileSync(
"node",
[
"--input-type=module",
"-e",
`import {verifyBuild} from './scripts/check-build.mjs'; console.log(verifyBuild(${JSON.stringify(temporary)}));`,
],
{ encoding: "utf8", stdio: "pipe" },
);
try {
cpSync("public", temporary, { recursive: true });
expect(verify().trim()).toBe("101");
const notice = join(temporary, "THIRD_PARTY_NOTICES.md");
if (damage === "누락") rmSync(notice, { force: true });
else
writeFileSync(notice, "React / ReactDOM / scheduler — MIT License");
expect(verify).toThrow(/notice/i);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
},
);
});
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import {
ENVIRONMENTS,
LEVELS,
PATTERNS,
RULE_DIFFICULTY,
WORDS,
} from "../src/content";
// 개수만 맞추는 더미 데이터를 방지하기 위해 식별자·템플릿·변형 규칙까지
// 검증한다. 사전 등재 여부의 출처와 의미는 별도의 단어 목록 문서로 검토한다.
describe("저장된 문제·환경 데이터", () => {
it("열 패턴은 서로 다른 청취 연산 mechanic을 하나씩 가진다", () => {
expect(PATTERNS).toHaveLength(10);
expect(new Set(PATTERNS.map((p) => p.mechanic))).toEqual(
new Set([
"direct-target",
"distractor-password",
"ignore-then-remember",
"first",
"last",
"middle",
"repetition",
"correction",
"conditional-cue",
"spatial-right-cue",
]),
);
});
// 문구가 서로 다르다는 사실만으로는 청취 과제가 달라지지 않는다. 각 문장에
// 실제 경쟁 단어가 있고, 순서·반복 횟수·취소·조건·방향으로 답이 정해져야 한다.
it.each([
{
id: "luck",
mechanic: "direct-target",
order: [0],
cues: [/행운/],
question: /행운/,
},
{
id: "password",
mechanic: "distractor-password",
order: [1, 0],
cues: [/연습 암호/, /진짜 암호/],
question: /진짜 암호/,
},
{
id: "remember",
mechanic: "ignore-then-remember",
order: [1, 0],
cues: [/잊으세요.*이제.*기억하세요/],
question: /기억/,
},
{
id: "first",
mechanic: "first",
order: [0, 1, 2],
cues: [/세 단어.*첫/],
question: /첫/,
},
{
id: "last",
mechanic: "last",
order: [1, 2, 0],
cues: [/세 단어.*마지막/],
question: /마지막/,
},
{
id: "middle",
mechanic: "middle",
order: [1, 0, 2],
cues: [/세 단어.*가운데/],
question: /가운데/,
},
{
id: "repeat",
mechanic: "repetition",
order: [0, 1, 0, 2],
cues: [/두 번/],
question: /두 번/,
},
{
id: "correction",
mechanic: "correction",
order: [1, 0],
cues: [/아니.*잘못.*정정/],
question: /정정/,
},
{
id: "message",
mechanic: "conditional-cue",
order: [1, 0],
cues: [/초록 불이면 앞 단어/, /빨간 불이면 뒤 단어/, /지금은 빨간 불/],
question: /신호/,
},
{
id: "mission",
mechanic: "spatial-right-cue",
order: [0, 1],
cues: [/오른쪽 표지.*왼쪽 표지/],
question: /오른쪽/,
},
])(
"$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다",
({ id, mechanic, order, cues, question }) => {
const pattern = PATTERNS.find((p) => p.id === id)!;
expect.soft(pattern.mechanic).toBe(mechanic);
expect.soft(pattern.question).toMatch(question);
cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue));
for (const entry of WORDS) {
const choices = [entry.answer, ...entry.distractors.map((d) => d.word)];
const sentence = pattern.sentence
.replaceAll("{word}", choices[0]!)
.replaceAll("{distractor1}", choices[1]!)
.replaceAll("{distractor2}", choices[2]!);
const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map(
(match) => match[1],
);
expect.soft(heard).toEqual(order.map((index) => choices[index]));
expect(sentence).not.toMatch(/[{}]/);
// 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다.
expect
.soft(heard.filter((word) => word === entry.answer))
.toHaveLength(mechanic === "repetition" ? 2 : 1);
}
},
);
it("오답마다 서로 다른 변형 혼동 난이도를 추적할 수 있다", () => {
WORDS.forEach((entry) => {
const ranks = entry.distractors.map((d) => RULE_DIFFICULTY[d.rule]);
ranks.forEach((rank) => expect(rank).toBeGreaterThan(0));
expect(new Set(ranks).size).toBe(3);
});
});
it("서로 다른 질문 의도를 가진 열 가지 이상 문장 패턴을 제공한다", () => {
expect(PATTERNS.length).toBeGreaterThanOrEqual(10);
expect(new Set(PATTERNS.map((p) => p.id)).size).toBe(PATTERNS.length);
expect(new Set(PATTERNS.map((p) => p.question)).size).toBe(PATTERNS.length);
PATTERNS.forEach((p) => expect(p.sentence).toContain("{word}"));
});
it("네 개의 고유 사전 단어와 서로 다른 오답 규칙을 제공한다", () => {
expect(WORDS.length).toBeGreaterThanOrEqual(10);
WORDS.forEach((entry) => {
expect(entry.distractors).toHaveLength(3);
expect(
new Set([entry.answer, ...entry.distractors.map((d) => d.word)]).size,
).toBe(4);
expect(new Set(entry.distractors.map((d) => d.rule)).size).toBe(3);
// 한글 완성형을 초성·중성·종성으로 분해하여 설명과 실제 변형이 일치하는지 본다.
for (const { word, rule } of entry.distractors) {
const split = (s: string) =>
[...s].map((c) => {
const n = c.charCodeAt(0) - 0xac00;
return [Math.floor(n / 588), Math.floor(n / 28) % 21, n % 28];
});
const before = split(entry.answer).flat();
const after = split(word).flat();
const changed = before.flatMap((v, i) =>
v === after[i] ? [] : [i % 3],
);
if (rule !== "음절 교체") {
expect(changed).toHaveLength(1);
expect(changed[0]).toBe(
["초성 교체", "모음 교체", "받침 교체"].indexOf(rule),
);
}
}
});
});
it("여덟 환경과 단조롭게 낮아지는 다섯 상대 dB를 제공한다", () => {
expect(ENVIRONMENTS.map((e) => e.name)).toEqual([
"길거리",
"쇼핑몰",
"극장",
"백화점",
"시장",
"지하철",
"카페",
"비 오는 거리",
]);
expect(new Set(ENVIRONMENTS.map((e) => e.seed)).size).toBe(8);
expect(LEVELS).toHaveLength(5);
LEVELS.forEach((l, i) => {
if (i) expect(l.db).toBeLessThan(LEVELS[i - 1]!.db);
});
expect(PATTERNS.length * WORDS.length).toBeGreaterThan(10);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { ENVIRONMENTS, LEVELS } from "../src/content";
import { dbToGain, normalizeRms, synthesizeBackground } from "../src/audio/dsp";
// 청감이나 실제 음압은 장치마다 다르므로 테스트 대상은 디지털 RMS와 gain
// 비율이다. 배경·음성을 같은 기준으로 정규화해야 표기한 상대 dB가 성립한다.
const rms = (a: Float32Array) =>
Math.sqrt(a.reduce((sum, n) => sum + n * n, 0) / a.length);
describe("상대 dB와 결정론적 배경 합성", () => {
it("20 log10(음성 gain / 배경 gain)이 표시 dB와 일치한다", () => {
for (const level of LEVELS)
expect(20 * Math.log10(dbToGain(level.db))).toBeCloseTo(level.db, 10);
expect(dbToGain(-6)).toBeCloseTo(0.501187, 5);
});
it("평균 전력을 맞추고 원본 배열을 변경하지 않으며 무음은 오류로 알린다", () => {
const input = new Float32Array([0.4, -0.2, 0.1, -0.3]);
const output = normalizeRms(input);
expect(rms(output)).toBeCloseTo(0.12, 6);
expect(output).not.toBe(input);
expect(() => normalizeRms(new Float32Array(4))).toThrow("무음");
});
it("여덟 환경이 서로 다르고 같은 seed는 같은 안전 범위 신호를 만든다", () => {
const signatures = new Set<string>();
for (const preset of ENVIRONMENTS) {
const signal = synthesizeBackground(preset, 16000, 1);
expect(signal).toHaveLength(16000);
expect(signal).toEqual(synthesizeBackground(preset, 16000, 1));
expect(rms(signal)).toBeCloseTo(0.12, 5);
expect(Math.max(...signal.map(Math.abs))).toBeLessThan(1);
// 루프 경계에서 불연속 클릭이 생기지 않도록 양 끝을 0으로 감싼다.
expect(signal[0]).toBe(0);
expect(signal.at(-1)).toBe(0);
signatures.add([...signal.slice(0, 100)].join(","));
}
expect(signatures.size).toBe(8);
});
});
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
createQuestions,
gameReducer,
initialGame,
isCorrect,
normalizeAnswer,
summarize,
} from "../src/game";
// 고정 seed로 재현 가능성을 확보하되 seed를 바꾸면 10문제를 넘는 조합이
// 실제로 선택되는지 본다. 오디오 URL도 같은 문장 조합을 가리켜야 한다.
describe("출제와 정답 판정", () => {
it("실제 보기에 있는 경쟁 단어를 문장에 넣고 청취 연산별 순서를 유지한다", () => {
for (let seed = 0; seed < 20; seed++) {
for (const q of createQuestions(seed, "choice")) {
expect(q.sentence).not.toMatch(/[{}]/);
expect(q.sentence).toContain(`‘${q.word.answer}’`);
if (q.pattern.id !== "luck") {
expect(q.sentence).toContain(`‘${q.word.distractors[0]!.word}’`);
}
const expected = q.pattern.sentence
.replaceAll("{word}", q.word.answer)
.replaceAll("{distractor1}", q.word.distractors[0]!.word)
.replaceAll("{distractor2}", q.word.distractors[1]!.word);
expect(q.sentence).toBe(expected);
}
}
});
it("열 문제에 모든 유형·다섯 난이도·두 응답 방식을 섞는다", () => {
const questions = createQuestions(42, "mixed");
expect(questions).toHaveLength(10);
expect(new Set(questions.map((q) => q.pattern.id)).size).toBe(10);
expect(new Set(questions.map((q) => q.environment.id)).size).toBe(8);
expect(questions.map((q) => q.level)).toEqual([
1, 1, 2, 2, 3, 3, 4, 4, 5, 5,
]);
expect(new Set(questions.map((q) => q.mode))).toEqual(
new Set(["choice", "text"]),
);
questions.forEach((q) => {
expect(q.options).toHaveLength(4);
expect(new Set(q.options).size).toBe(4);
expect(q.options.filter((o) => o === q.word.answer)).toHaveLength(1);
expect(q.sentence).not.toContain("{word}");
expect(q.audioUrl).toBe(`/audio/${q.pattern.id}-${q.word.id}.wav`);
});
expect(createQuestions(42, "mixed")).toEqual(questions);
expect(
new Set([...questions, ...createQuestions(91, "mixed")].map((q) => q.id))
.size,
).toBeGreaterThan(10);
expect(
new Set(questions.map((q) => q.options.indexOf(q.word.answer))).size,
).toBeGreaterThan(1);
});
it("선택한 응답 방식과 환경을 모든 문제에 적용한다", () => {
expect(
createQuestions(3, "text", "cafe").every(
(q) => q.mode === "text" && q.environment.id === "cafe",
),
).toBe(true);
expect(createQuestions(3, "choice").every((q) => q.mode === "choice")).toBe(
true,
);
});
it("한글 정규형·공백·문장부호만 정리하고 부분 정답은 인정하지 않는다", () => {
expect(normalizeAnswer(" “사 과.” ")).toBe("사과");
expect(isCorrect("사과".normalize("NFD"), "사과")).toBe(true);
expect(isCorrect("사과 또는 바다", "사과")).toBe(false);
expect(isCorrect("", "사과")).toBe(false);
expect(isCorrect("사고", "사과")).toBe(false);
});
});
describe("게임 상태·점수·경합 방지", () => {
it("재생 완료 이전에는 답변·다음 문제를 허용하지 않으며 중복 채점을 막는다", () => {
let state = initialGame(createQuestions(42, "choice"));
expect(
gameReducer(state, {
type: "ANSWER",
input: state.questions[0]!.word.answer,
}),
).toBe(state);
state = gameReducer(state, { type: "PLAY" });
expect(state.phase).toBe("loading");
expect(gameReducer(state, { type: "PLAY" })).toBe(state);
expect(gameReducer(state, { type: "NEXT" })).toBe(state);
state = gameReducer(state, { type: "STARTED" });
expect(state.phase).toBe("playing");
state = gameReducer(state, { type: "ENDED" });
expect(state.phase).toBe("answer");
state = gameReducer(state, {
type: "ANSWER",
input: state.questions[0]!.word.answer,
});
expect(state.phase).toBe("feedback");
expect(gameReducer(state, { type: "ANSWER", input: "오답" })).toBe(state);
expect(state.answers[0]!.points).toBe(100);
});
it("오디오 오류 후 재시도와 다시 듣기는 점수를 변경하지 않는다", () => {
let state = initialGame(createQuestions(4, "mixed"));
state = gameReducer(state, { type: "PLAY" });
state = gameReducer(state, {
type: "ERROR",
message: "음성 파일을 읽지 못했습니다.",
});
expect(state.phase).toBe("ready");
expect(state.error).toContain("음성");
state = gameReducer(state, { type: "PLAY" });
expect(state.error).toBeNull();
state = gameReducer(gameReducer(state, { type: "STARTED" }), {
type: "ENDED",
});
state = gameReducer(state, { type: "PLAY" });
expect(state.phase).toBe("loading");
expect(state.answers).toHaveLength(0);
});
it("열 문제를 끝내면 정답률·연속 정답·단계별 결과를 집계한다", () => {
let state = initialGame(createQuestions(5, "choice"));
expect(summarize(state).accuracy).toBe(0);
for (let i = 0; i < 10; i++) {
state = gameReducer(state, { type: "PLAY" });
state = gameReducer(state, { type: "STARTED" });
state = gameReducer(state, { type: "ENDED" });
state = gameReducer(state, {
type: "ANSWER",
input: i === 2 ? "오답" : state.questions[i]!.word.answer,
});
state = gameReducer(state, { type: "NEXT" });
}
expect(state.phase).toBe("results");
expect(state.bestStreak).toBe(7);
expect(state.streak).toBe(7);
const result = summarize(state);
expect(result.accuracy).toBe(90);
expect(result.correct).toBe(9);
expect(result.score).toBe(2990);
expect(result.byLevel).toEqual(
[1, 2, 3, 4, 5].map((level) => ({
level,
total: 2,
correct: level === 2 ? 1 : 2,
})),
);
expect(gameReducer(state, { type: "PLAY" })).toBe(state);
});
});
+45
View File
@@ -0,0 +1,45 @@
// @vitest-environment node
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
// args에서 플래그만 지워도 Playwright 기본값이 sandbox를 끌 수 있다.
// 실제 설정을 새 프로세스로 읽어 환경변수 캐시와 개발자 환경에 영향받지 않는다.
function launchOptions(optIn?: string) {
const env: NodeJS.ProcessEnv = {
...process.env,
CHROME_PATH: "/usr/bin/google-chrome",
};
delete env.PLAYWRIGHT_NO_SANDBOX;
if (optIn !== undefined) env.PLAYWRIGHT_NO_SANDBOX = optIn;
return JSON.parse(
execFileSync(
process.execPath,
[
"--experimental-strip-types",
"--input-type=module",
"-e",
"import config from './playwright.config.ts'; console.log(JSON.stringify(config.use.launchOptions));",
],
{ encoding: "utf8", env },
),
) as {
chromiumSandbox?: boolean;
args?: string[];
executablePath?: string;
};
}
describe("브라우저 sandbox 기본 정책", () => {
it.each([undefined, "", "0", "false"])(
"opt-in=%s이면 sandbox를 켠다",
(value) => {
const options = launchOptions(value);
expect.soft(options.chromiumSandbox).toBe(true);
expect.soft(options.args ?? []).not.toContain("--no-sandbox");
expect(options.executablePath).toBe("/usr/bin/google-chrome");
},
);
it("명시적인 PLAYWRIGHT_NO_SANDBOX=1에서만 sandbox를 끈다", () => {
expect(launchOptions("1").chromiumSandbox).toBe(false);
});
});
+77
View File
@@ -0,0 +1,77 @@
// @vitest-environment node
import { readFileSync, readdirSync } from "node:fs";
import { describe, expect, it } from "vitest";
// 사용자 지정 문자열은 편집자가 회사 이름을 추측해 정규화하지 못하도록
// 화면 테스트와 문서 계약에서 원문 그대로 고정한다.
describe("릴리스 문서 계약", () => {
it("Node 최소 버전은 잠긴 의존성이 요구하는 22.22.2와 일치한다", () => {
const metadata = JSON.parse(readFileSync("package.json", "utf8"));
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
expect.soft(metadata.engines.node).toBe(">=22.22.2");
expect.soft(lock.packages[""].engines.node).toBe(">=22.22.2");
expect(lock.packages["node_modules/jsdom"].engines.node).toContain(
"^22.22.2",
);
for (const path of ["README.md", "docs/DEPLOYMENT.md"]) {
const document = readFileSync(path, "utf8");
expect.soft(document, path).toContain("22.22.2");
expect.soft(document, path).not.toContain("22.12");
}
});
it("프로젝트는 비공개 권리 유보이며 저장소 주소를 명시한다", () => {
const metadata = JSON.parse(readFileSync("package.json", "utf8"));
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
expect.soft(metadata.private).toBe(true);
expect.soft(metadata.license).toBe("UNLICENSED");
expect.soft(lock.packages[""].license).toBe("UNLICENSED");
expect.soft(metadata.repository).toEqual({
type: "git",
url: "https://source.ibit.kr/OZ-Group/Find-Word.git",
});
expect
.soft(metadata.homepage)
.toBe("https://source.ibit.kr/OZ-Group/Find-Word");
const license = readFileSync("LICENSE", "utf8");
expect(license).toMatch(/private/i);
expect(license).toContain("All rights reserved.");
expect(readFileSync("README.md", "utf8")).toContain(metadata.homepage);
});
it("잠금 파일의 모든 런타임 의존성 notice 전문을 배포용 사본에 보존한다", () => {
const lock = JSON.parse(readFileSync("package-lock.json", "utf8")) as {
packages: Record<string, { dev?: boolean; version: string }>;
};
const notice = readFileSync("THIRD_PARTY_NOTICES.md", "utf8");
expect(readFileSync("public/THIRD_PARTY_NOTICES.md", "utf8")).toBe(notice);
for (const [path, pkg] of Object.entries(lock.packages)) {
if (!path || pkg.dev) continue;
const name = path.split("node_modules/").at(-1)!;
const license = readFileSync(`${path}/LICENSE`, "utf8").trim();
expect(notice).toContain(`## ${name} ${pkg.version}\n\n${license}`);
}
});
it("브랜드 문구를 사용자 원문 그대로 문서에도 보존한다", () => {
const credit = "Designed by OZ at Heat-T Medical Inc.";
for (const path of [
"README.md",
".agent/TASK.md",
".agent/ACCEPTANCE.md",
".agent/CONTEXT.md",
]) {
expect.soft(readFileSync(path, "utf8"), path).toContain(credit);
}
for (const path of [
"README.md",
...["docs", ".agent"].flatMap((dir) =>
readdirSync(dir)
.filter((file) => file.endsWith(".md"))
.map((file) => `${dir}/${file}`),
),
]) {
// 잘못된 표기를 다시 권장하는 문서가 남아 있으면 다음 편집 때 회귀한다.
expect
.soft(readFileSync(path, "utf8"), path)
.not.toContain(["HearT", "M"].join("-"));
}
});
});
+7
View File
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// 각 시나리오의 DOM과 이벤트 구독을 제거하여 이전 문제의 상태가 다음
// 테스트에 누출되는 일을 막는다. 오디오 성공 여부는 테스트가 직접 제어한다.
afterEach(cleanup);