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([ "길거리", "쇼핑몰", "극장", "백화점", "시장", "지하철", "카페", "비 오는 거리", ]); expect(new Set(ENVIRONMENTS.map((e) => e.audioUrl)).size).toBe(8); 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); }); });