Files
Find-Word/tests/game.test.ts
T

169 lines
6.7 KiB
TypeScript
Raw 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 { describe, expect, it } from "vitest";
import {
createQuestions,
gameReducer,
initialGame,
isCorrect,
normalizeAnswer,
summarize,
} from "../src/game";
// 고정 seed로 재현 가능성을 확보하되 seed를 바꾸면 10문제를 넘는 조합이
// 실제로 선택되는지 본다. 오디오 URL도 같은 문장 조합을 가리켜야 한다.
describe("출제와 정답 판정", () => {
it("목표 WAV 100개 모두 같은 명시적 cache-busting 버전과 정답 mapping을 유지한다", () => {
const questions = new Map(
Array.from({ length: 200 }, (_, 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.1.1"]));
});
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(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(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);
});
});