feat: release Find-Word v0.2.3
This commit is contained in:
+122
-19
@@ -1,3 +1,4 @@
|
||||
import { LEVELS } from "../src/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createQuestions,
|
||||
@@ -8,12 +9,13 @@ import {
|
||||
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: 200 }, (_, seed) => createQuestions(seed, "mixed"))
|
||||
Array.from({ length: 1000 }, (_, seed) => createQuestions(seed, "mixed"))
|
||||
.flat()
|
||||
.map((q) => [q.id, q]),
|
||||
);
|
||||
@@ -27,7 +29,7 @@ describe("출제와 정답 판정", () => {
|
||||
expect(q.options).toContain(q.word.answer);
|
||||
versions.add(url.searchParams.get("v"));
|
||||
}
|
||||
expect(versions).toEqual(new Set(["0.1.1"]));
|
||||
expect(versions).toEqual(new Set(["0.2.3"]));
|
||||
});
|
||||
it("실제 보기에 있는 경쟁 단어를 문장에 넣고 청취 연산별 순서를 유지한다", () => {
|
||||
for (let seed = 0; seed < 20; seed++) {
|
||||
@@ -45,14 +47,12 @@ describe("출제와 정답 판정", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
it("열 문제에 모든 유형·다섯 난이도·두 응답 방식을 섞는다", () => {
|
||||
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(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"]),
|
||||
);
|
||||
@@ -69,7 +69,7 @@ describe("출제와 정답 판정", () => {
|
||||
expect(
|
||||
new Set([...questions, ...createQuestions(91, "mixed")].map((q) => q.id))
|
||||
.size,
|
||||
).toBeGreaterThan(10);
|
||||
).toBeGreaterThan(3);
|
||||
expect(
|
||||
new Set(questions.map((q) => q.options.indexOf(q.word.answer))).size,
|
||||
).toBeGreaterThan(1);
|
||||
@@ -136,10 +136,10 @@ describe("게임 상태·점수·경합 방지", () => {
|
||||
expect(state.phase).toBe("loading");
|
||||
expect(state.answers).toHaveLength(0);
|
||||
});
|
||||
it("열 문제를 끝내면 정답률·연속 정답·단계별 결과를 집계한다", () => {
|
||||
it("세 문제를 끝내면 정답률·연속 정답·단계별 결과를 집계한다", () => {
|
||||
let state = initialGame(createQuestions(5, "choice"));
|
||||
expect(summarize(state).accuracy).toBe(0);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
state = gameReducer(state, { type: "PLAY" });
|
||||
state = gameReducer(state, { type: "STARTED" });
|
||||
state = gameReducer(state, { type: "ENDED" });
|
||||
@@ -150,19 +150,122 @@ describe("게임 상태·점수·경합 방지", () => {
|
||||
state = gameReducer(state, { type: "NEXT" });
|
||||
}
|
||||
expect(state.phase).toBe("results");
|
||||
expect(state.bestStreak).toBe(7);
|
||||
expect(state.streak).toBe(7);
|
||||
expect(state.bestStreak).toBe(2);
|
||||
expect(state.streak).toBe(0);
|
||||
const result = summarize(state);
|
||||
expect(result.accuracy).toBe(90);
|
||||
expect(result.correct).toBe(9);
|
||||
expect(result.score).toBe(2990);
|
||||
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: 2,
|
||||
correct: level === 2 ? 1 : 2,
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user