Files
Find-Word/tests/game.test.ts
T
2026-10-14 10:01:46 +00:00

272 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LEVELS } from "../src/content";
import { describe, expect, it } from "vitest";
import {
createQuestions,
gameReducer,
initialGame,
isCorrect,
normalizeAnswer,
summarize,
} from "../src/game";
// 세 문항 라운드에서는 1000개 seed를 표본으로 전체 100조합 도달성을 확인한다.
// 고정 seed로 재현 가능성을 확보하되 seed를 바꾸면 10문제를 넘는 조합이
// 실제로 선택되는지 본다. 오디오 URL도 같은 문장 조합을 가리켜야 한다.
describe("출제와 정답 판정", () => {
it("목표 WAV 100개 모두 같은 명시적 cache-busting 버전과 정답 mapping을 유지한다", () => {
const questions = new Map(
Array.from({ length: 1000 }, (_, seed) => createQuestions(seed, "mixed"))
.flat()
.map((q) => [q.id, q]),
);
expect(questions.size).toBe(100);
const versions = new Set<string | null>();
for (const q of questions.values()) {
const url = new URL(q.audioUrl, "http://localhost");
expect(url.origin).toBe("http://localhost");
expect(url.pathname).toBe(`/audio/${q.pattern.id}-${q.word.id}.wav`);
expect(q.id).toBe(`${q.pattern.id}-${q.word.id}`);
expect(q.options).toContain(q.word.answer);
versions.add(url.searchParams.get("v"));
}
expect(versions).toEqual(new Set(["0.2.3"]));
});
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(3);
expect(new Set(questions.map((q) => q.pattern.id)).size).toBe(3);
expect(new Set(questions.map((q) => q.environment.id)).size).toBe(3);
expect(questions.map((q) => q.level)).toEqual([1, 1, 1]);
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(new URL(q.audioUrl, "http://localhost").pathname).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(3);
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 < 3; 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(2);
expect(state.streak).toBe(0);
const result = summarize(state);
expect(result.accuracy).toBe(67);
expect(result.correct).toBe(2);
expect(result.score).toBe(210);
expect(result.byLevel).toEqual(
[1, 2, 3, 4, 5].map((level) => ({
level,
total: level === 1 ? 3 : 0,
correct: level === 1 ? 2 : 0,
})),
);
expect(gameReducer(state, { type: "PLAY" })).toBe(state);
});
});
// 시작 단계는 라운드 계약이며 난수나 문항 인덱스가 이를 덮어쓰면 안 된다.
it("시작 단계 1~5를 명시하면 세 문항 모두 같은 level/db이고 범위 밖 단계도 안전하다", () => {
for (const level of LEVELS) {
const questions = createQuestions(42, "mixed", "rotate", level.id);
expect(questions.map((q) => [q.level, q.db])).toEqual(
Array(3).fill([level.id, level.db]),
);
}
for (const [input, expected] of [
[NaN, 1],
[-4, 1],
[99, 5],
[2.7, 3],
])
expect(createQuestions(1, "choice", "cafe", input)[0]!.level).toBe(
expected,
);
});
// SNR 변경은 과거 채점의 청취 조건을 보존하고 현재/미응답 문항에만 전파한다.
it("SNR을 정수 -12~12 dB로 제한하고 현재/남은 문항에 일관되게 적용한다", () => {
let state = initialGame(createQuestions(42, "choice", "street", 2));
for (const [input, expected] of [
[-99, -12],
[99, 12],
[NaN, 3],
[1.6, 2],
]) {
const adjusted = gameReducer(state, { type: "SNR", db: input! });
expect(adjusted.questions.map((q) => q.db)).toEqual([
expected,
expected,
expected,
]);
}
for (const type of ["PLAY", "STARTED", "ENDED"] as const)
state = gameReducer(state, { type });
state = gameReducer(state, {
type: "ANSWER",
input: state.questions[0]!.word.answer,
});
state = gameReducer(state, { type: "SNR", db: -4 });
expect(state.questions.map((q) => q.db)).toEqual([3, -4, -4]);
expect(state.answers[0]).toMatchObject({ db: 3 });
});
// 종료 뒤 지연 이벤트가 라운드를 되살리지 않는지 모든 단계에서 확인한다.
it.each(["ready", "loading", "playing", "answer", "feedback"] as const)(
"%s에서 중도 종료해도 무응답을 더하지 않고 종료 결과를 유지한다",
(phase) => {
const initial = initialGame(createQuestions(1, "choice"));
const ended = gameReducer({ ...initial, phase }, { type: "EXIT" });
expect(ended).toMatchObject({
phase: "results",
endedEarly: true,
answers: [],
});
expect(summarize(ended)).toMatchObject({
answered: 0,
correct: 0,
accuracy: 0,
});
for (const action of [
{ type: "PLAY" },
{ type: "STARTED" },
{ type: "ENDED" },
{ type: "NEXT" },
{ type: "EXIT" },
{ type: "ANSWER", input: "사과" },
{ type: "ERROR", message: "late" },
{ type: "SNR", db: 5 },
] as const)
expect(gameReducer(ended, action)).toBe(ended);
},
);
// 재생 중 SNR을 바꾸면 현재 음원에는 아직 적용되지 않는다. 결과의 dB는 실제로
// 마지막 완료한 청취 조건을 기록해야 다음 재생용 선택값을 들었다고 오기하지 않는다.
it("재생 중 변경된 SNR은 재청취 전 답변의 청취 이력을 덮어쓰지 않는다", () => {
let state = initialGame(createQuestions(42, "choice"));
state = gameReducer(state, { type: "PLAY" });
state = gameReducer(state, { type: "STARTED" });
state = gameReducer(state, { type: "SNR", db: -10 });
state = gameReducer(state, { type: "ENDED" });
state = gameReducer(state, {
type: "ANSWER",
input: state.questions[0]!.word.answer,
});
expect(state.answers[0]!.db).toBe(6);
expect(state.questions[1]!.db).toBe(-10);
});
// 세 문항으로 줄여도 순환 환경이 앞의 세 장소에 갇히지 않아야 한다.
it("순환 환경은 여러 라운드에서 여덟 장소 모두를 선택하고 라운드 내에는 중복되지 않는다", () => {
const seen = new Set<string>();
for (let seed = 0; seed < 100; seed++) {
const questions = createQuestions(seed, "mixed");
expect(new Set(questions.map((q) => q.environment.id)).size).toBe(3);
questions.forEach((q) => seen.add(q.environment.id));
}
expect(seen.size).toBe(8);
});