2026-10-14 06:19:24 +00:00
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
|
import {
|
|
|
|
|
|
createQuestions,
|
|
|
|
|
|
gameReducer,
|
|
|
|
|
|
initialGame,
|
|
|
|
|
|
isCorrect,
|
|
|
|
|
|
normalizeAnswer,
|
|
|
|
|
|
summarize,
|
|
|
|
|
|
} from "../src/game";
|
|
|
|
|
|
|
|
|
|
|
|
// 고정 seed로 재현 가능성을 확보하되 seed를 바꾸면 10문제를 넘는 조합이
|
|
|
|
|
|
// 실제로 선택되는지 본다. 오디오 URL도 같은 문장 조합을 가리켜야 한다.
|
|
|
|
|
|
describe("출제와 정답 판정", () => {
|
2026-10-14 07:31:47 +00:00
|
|
|
|
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"]));
|
|
|
|
|
|
});
|
2026-10-14 06:19:24 +00:00
|
|
|
|
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}");
|
2026-10-14 07:31:47 +00:00
|
|
|
|
expect(new URL(q.audioUrl, "http://localhost").pathname).toBe(
|
|
|
|
|
|
`/audio/${q.pattern.id}-${q.word.id}.wav`,
|
|
|
|
|
|
);
|
2026-10-14 06:19:24 +00:00
|
|
|
|
});
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|