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(); 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.5"])); }); 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(); 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); });