2026-10-14 06:19:24 +00:00
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
|
import {
|
|
|
|
|
|
ENVIRONMENTS,
|
|
|
|
|
|
LEVELS,
|
|
|
|
|
|
PATTERNS,
|
|
|
|
|
|
RULE_DIFFICULTY,
|
|
|
|
|
|
WORDS,
|
|
|
|
|
|
} from "../src/content";
|
|
|
|
|
|
|
|
|
|
|
|
// 개수만 맞추는 더미 데이터를 방지하기 위해 식별자·템플릿·변형 규칙까지
|
|
|
|
|
|
// 검증한다. 사전 등재 여부의 출처와 의미는 별도의 단어 목록 문서로 검토한다.
|
|
|
|
|
|
describe("저장된 문제·환경 데이터", () => {
|
|
|
|
|
|
it("열 패턴은 서로 다른 청취 연산 mechanic을 하나씩 가진다", () => {
|
|
|
|
|
|
expect(PATTERNS).toHaveLength(10);
|
|
|
|
|
|
expect(new Set(PATTERNS.map((p) => p.mechanic))).toEqual(
|
|
|
|
|
|
new Set([
|
|
|
|
|
|
"direct-target",
|
|
|
|
|
|
"distractor-password",
|
|
|
|
|
|
"ignore-then-remember",
|
|
|
|
|
|
"first",
|
|
|
|
|
|
"last",
|
|
|
|
|
|
"middle",
|
|
|
|
|
|
"repetition",
|
|
|
|
|
|
"correction",
|
|
|
|
|
|
"conditional-cue",
|
|
|
|
|
|
"spatial-right-cue",
|
|
|
|
|
|
]),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
// 문구가 서로 다르다는 사실만으로는 청취 과제가 달라지지 않는다. 각 문장에
|
|
|
|
|
|
// 실제 경쟁 단어가 있고, 순서·반복 횟수·취소·조건·방향으로 답이 정해져야 한다.
|
|
|
|
|
|
it.each([
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "luck",
|
|
|
|
|
|
mechanic: "direct-target",
|
|
|
|
|
|
order: [0],
|
|
|
|
|
|
cues: [/행운/],
|
|
|
|
|
|
question: /행운/,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "password",
|
|
|
|
|
|
mechanic: "distractor-password",
|
|
|
|
|
|
order: [1, 0],
|
|
|
|
|
|
cues: [/연습 암호/, /진짜 암호/],
|
|
|
|
|
|
question: /진짜 암호/,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "remember",
|
|
|
|
|
|
mechanic: "ignore-then-remember",
|
|
|
|
|
|
order: [1, 0],
|
|
|
|
|
|
cues: [/잊으세요.*이제.*기억하세요/],
|
|
|
|
|
|
question: /기억/,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "first",
|
|
|
|
|
|
mechanic: "first",
|
|
|
|
|
|
order: [0, 1, 2],
|
|
|
|
|
|
cues: [/세 단어.*첫/],
|
|
|
|
|
|
question: /첫/,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "last",
|
|
|
|
|
|
mechanic: "last",
|
|
|
|
|
|
order: [1, 2, 0],
|
|
|
|
|
|
cues: [/세 단어.*마지막/],
|
|
|
|
|
|
question: /마지막/,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "middle",
|
|
|
|
|
|
mechanic: "middle",
|
|
|
|
|
|
order: [1, 0, 2],
|
|
|
|
|
|
cues: [/세 단어.*가운데/],
|
|
|
|
|
|
question: /가운데/,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "repeat",
|
|
|
|
|
|
mechanic: "repetition",
|
|
|
|
|
|
order: [0, 1, 0, 2],
|
|
|
|
|
|
cues: [/두 번/],
|
|
|
|
|
|
question: /두 번/,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "correction",
|
|
|
|
|
|
mechanic: "correction",
|
|
|
|
|
|
order: [1, 0],
|
|
|
|
|
|
cues: [/아니.*잘못.*정정/],
|
|
|
|
|
|
question: /정정/,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "message",
|
|
|
|
|
|
mechanic: "conditional-cue",
|
|
|
|
|
|
order: [1, 0],
|
|
|
|
|
|
cues: [/초록 불이면 앞 단어/, /빨간 불이면 뒤 단어/, /지금은 빨간 불/],
|
|
|
|
|
|
question: /신호/,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "mission",
|
|
|
|
|
|
mechanic: "spatial-right-cue",
|
|
|
|
|
|
order: [0, 1],
|
|
|
|
|
|
cues: [/오른쪽 표지.*왼쪽 표지/],
|
|
|
|
|
|
question: /오른쪽/,
|
|
|
|
|
|
},
|
|
|
|
|
|
])(
|
|
|
|
|
|
"$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다",
|
|
|
|
|
|
({ id, mechanic, order, cues, question }) => {
|
|
|
|
|
|
const pattern = PATTERNS.find((p) => p.id === id)!;
|
|
|
|
|
|
expect.soft(pattern.mechanic).toBe(mechanic);
|
|
|
|
|
|
expect.soft(pattern.question).toMatch(question);
|
|
|
|
|
|
cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue));
|
|
|
|
|
|
for (const entry of WORDS) {
|
|
|
|
|
|
const choices = [entry.answer, ...entry.distractors.map((d) => d.word)];
|
|
|
|
|
|
const sentence = pattern.sentence
|
|
|
|
|
|
.replaceAll("{word}", choices[0]!)
|
|
|
|
|
|
.replaceAll("{distractor1}", choices[1]!)
|
|
|
|
|
|
.replaceAll("{distractor2}", choices[2]!);
|
|
|
|
|
|
const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map(
|
|
|
|
|
|
(match) => match[1],
|
|
|
|
|
|
);
|
|
|
|
|
|
expect.soft(heard).toEqual(order.map((index) => choices[index]));
|
|
|
|
|
|
expect(sentence).not.toMatch(/[{}]/);
|
|
|
|
|
|
// 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다.
|
|
|
|
|
|
expect
|
|
|
|
|
|
.soft(heard.filter((word) => word === entry.answer))
|
|
|
|
|
|
.toHaveLength(mechanic === "repetition" ? 2 : 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
it("오답마다 서로 다른 변형 혼동 난이도를 추적할 수 있다", () => {
|
|
|
|
|
|
WORDS.forEach((entry) => {
|
|
|
|
|
|
const ranks = entry.distractors.map((d) => RULE_DIFFICULTY[d.rule]);
|
|
|
|
|
|
ranks.forEach((rank) => expect(rank).toBeGreaterThan(0));
|
|
|
|
|
|
expect(new Set(ranks).size).toBe(3);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
it("서로 다른 질문 의도를 가진 열 가지 이상 문장 패턴을 제공한다", () => {
|
|
|
|
|
|
expect(PATTERNS.length).toBeGreaterThanOrEqual(10);
|
|
|
|
|
|
expect(new Set(PATTERNS.map((p) => p.id)).size).toBe(PATTERNS.length);
|
|
|
|
|
|
expect(new Set(PATTERNS.map((p) => p.question)).size).toBe(PATTERNS.length);
|
|
|
|
|
|
PATTERNS.forEach((p) => expect(p.sentence).toContain("{word}"));
|
|
|
|
|
|
});
|
|
|
|
|
|
it("네 개의 고유 사전 단어와 서로 다른 오답 규칙을 제공한다", () => {
|
|
|
|
|
|
expect(WORDS.length).toBeGreaterThanOrEqual(10);
|
|
|
|
|
|
WORDS.forEach((entry) => {
|
|
|
|
|
|
expect(entry.distractors).toHaveLength(3);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
new Set([entry.answer, ...entry.distractors.map((d) => d.word)]).size,
|
|
|
|
|
|
).toBe(4);
|
|
|
|
|
|
expect(new Set(entry.distractors.map((d) => d.rule)).size).toBe(3);
|
|
|
|
|
|
// 한글 완성형을 초성·중성·종성으로 분해하여 설명과 실제 변형이 일치하는지 본다.
|
|
|
|
|
|
for (const { word, rule } of entry.distractors) {
|
|
|
|
|
|
const split = (s: string) =>
|
|
|
|
|
|
[...s].map((c) => {
|
|
|
|
|
|
const n = c.charCodeAt(0) - 0xac00;
|
|
|
|
|
|
return [Math.floor(n / 588), Math.floor(n / 28) % 21, n % 28];
|
|
|
|
|
|
});
|
|
|
|
|
|
const before = split(entry.answer).flat();
|
|
|
|
|
|
const after = split(word).flat();
|
|
|
|
|
|
const changed = before.flatMap((v, i) =>
|
|
|
|
|
|
v === after[i] ? [] : [i % 3],
|
|
|
|
|
|
);
|
|
|
|
|
|
if (rule !== "음절 교체") {
|
|
|
|
|
|
expect(changed).toHaveLength(1);
|
|
|
|
|
|
expect(changed[0]).toBe(
|
|
|
|
|
|
["초성 교체", "모음 교체", "받침 교체"].indexOf(rule),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
it("여덟 환경과 단조롭게 낮아지는 다섯 상대 dB를 제공한다", () => {
|
|
|
|
|
|
expect(ENVIRONMENTS.map((e) => e.name)).toEqual([
|
|
|
|
|
|
"길거리",
|
|
|
|
|
|
"쇼핑몰",
|
|
|
|
|
|
"극장",
|
|
|
|
|
|
"백화점",
|
|
|
|
|
|
"시장",
|
|
|
|
|
|
"지하철",
|
|
|
|
|
|
"카페",
|
|
|
|
|
|
"비 오는 거리",
|
|
|
|
|
|
]);
|
2026-10-14 10:01:46 +00:00
|
|
|
|
expect(new Set(ENVIRONMENTS.map((e) => e.audioUrl)).size).toBe(8);
|
2026-10-14 06:19:24 +00:00
|
|
|
|
expect(LEVELS).toHaveLength(5);
|
|
|
|
|
|
LEVELS.forEach((l, i) => {
|
|
|
|
|
|
if (i) expect(l.db).toBeLessThan(LEVELS[i - 1]!.db);
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(PATTERNS.length * WORDS.length).toBeGreaterThan(10);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|