feat: release Find-Word v0.1
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import App from "../src/App";
|
||||
import type { AudioPort } from "../src/audio/engine";
|
||||
import { createQuestions } from "../src/game";
|
||||
|
||||
// 사용자가 보는 의미 역할로만 화면을 조작한다. 오디오 완료를 수동 제어하여
|
||||
// 재생 중에는 문제·선택지가 노출되지 않는지, 오류 뒤 재시도가 가능한지 본다.
|
||||
function fixture() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (reason: Error) => void;
|
||||
const audio: AudioPort = {
|
||||
play: vi.fn((_q, _intro, started) => {
|
||||
started();
|
||||
return new Promise<void>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
}),
|
||||
stop: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
setVolume: vi.fn(),
|
||||
};
|
||||
const user = userEvent.setup();
|
||||
const view = render(<App audio={audio} seed={42} />);
|
||||
return {
|
||||
audio,
|
||||
user,
|
||||
...view,
|
||||
finish: async () => {
|
||||
await act(async () => resolve());
|
||||
},
|
||||
fail: async () => {
|
||||
await act(async () => reject(new Error("파일 로드 실패")));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
it("시작 안내는 장식 번호 대신 8가지 환경을 표시한다", () => {
|
||||
fixture();
|
||||
expect(screen.getByText("8가지 환경", { exact: true })).toBeInTheDocument();
|
||||
expect(screen.queryByText("01 / 08")).not.toBeInTheDocument();
|
||||
});
|
||||
it("시작 안내의 현재 페이지 링크는 아래 화살표를 표시한다", () => {
|
||||
fixture();
|
||||
const link = screen.getByRole("link", { name: "플레이 방법" });
|
||||
expect(link).toHaveAttribute("href", "#howto");
|
||||
expect(link).toHaveTextContent("플레이 방법 ↓");
|
||||
expect(within(link).getByText("↓")).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
it("시작 안내의 CTA는 게임 시작과 소리 재생을 함께 알린다", async () => {
|
||||
const f = fixture();
|
||||
const start = screen.getByRole("button", {
|
||||
name: "게임 시작 · 소리 듣기",
|
||||
});
|
||||
expect(start).toHaveTextContent("게임 시작 · 소리 듣기");
|
||||
expect(f.audio.play).not.toHaveBeenCalled();
|
||||
await f.user.click(start);
|
||||
expect(f.audio.play).toHaveBeenCalledTimes(1);
|
||||
await f.finish();
|
||||
});
|
||||
it("전체 음량을 시작 CTA 앞 설정 카드와 게임 환경 패널에 배치하고 값을 유지한다", async () => {
|
||||
const f = fixture();
|
||||
const setup = screen.getByRole("region", {
|
||||
name: "나의 소리 탐험 준비",
|
||||
});
|
||||
const volume = screen.getByRole("slider", { name: "전체 음량" });
|
||||
const start = screen.getByRole("button", {
|
||||
name: "게임 시작 · 소리 듣기",
|
||||
});
|
||||
expect.soft(setup).toContainElement(volume);
|
||||
// CSS 배치만 바꾸지 않고 키보드·스크린리더도 음량→시작 순서로 읽어야 한다.
|
||||
expect
|
||||
.soft(
|
||||
volume.compareDocumentPosition(start) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
)
|
||||
.not.toBe(0);
|
||||
fireEvent.change(volume, { target: { value: "20" } });
|
||||
await f.user.click(start);
|
||||
const gameVolume = screen.getByRole("slider", { name: "전체 음량" });
|
||||
expect.soft(screen.getByRole("complementary")).toContainElement(gameVolume);
|
||||
expect(screen.getAllByRole("slider", { name: "전체 음량" })).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(gameVolume).toHaveValue("20");
|
||||
fireEvent.change(gameVolume, { target: { value: "60" } });
|
||||
expect(f.audio.setVolume).toHaveBeenLastCalledWith(0.6);
|
||||
await f.finish();
|
||||
await f.user.click(screen.getByRole("button", { name: "처음으로" }));
|
||||
expect(screen.getByRole("slider", { name: "전체 음량" })).toHaveValue("60");
|
||||
});
|
||||
it("다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다", async () => {
|
||||
const f = fixture();
|
||||
const questions = createQuestions(42, "choice");
|
||||
// 양수·0·음수를 각각 명시해 부호만 바꾸고 설명은 그대로인 회귀도 잡는다.
|
||||
const levels = [
|
||||
["SNR +6 dB", "음성이 배경보다 6 dB 커요."],
|
||||
["SNR +3 dB", "음성이 배경보다 3 dB 커요."],
|
||||
["SNR 0 dB", "음성과 배경의 크기가 같아요."],
|
||||
["SNR -3 dB", "음성이 배경보다 3 dB 작아요."],
|
||||
["SNR -6 dB", "음성이 배경보다 6 dB 작아요."],
|
||||
] as const;
|
||||
await f.user.click(screen.getByRole("radio", { name: "보기에서 선택" }));
|
||||
await f.user.click(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
);
|
||||
for (const [index, question] of questions.entries()) {
|
||||
if (index)
|
||||
await f.user.click(screen.getByRole("button", { name: /문장 듣기/ }));
|
||||
await f.finish();
|
||||
const [label, description] = levels[Math.floor(index / 2)]!;
|
||||
expect
|
||||
.soft(screen.queryByText(`${question.level}단계 · ${label}`))
|
||||
.toBeInTheDocument();
|
||||
expect
|
||||
.soft(screen.queryByText(description, { exact: false }))
|
||||
.toBeInTheDocument();
|
||||
await f.user.click(
|
||||
screen.getByRole("button", {
|
||||
name: new RegExp(question.word.answer),
|
||||
}),
|
||||
);
|
||||
await f.user.click(
|
||||
screen.getByRole("button", {
|
||||
name: index === 9 ? /결과 보기/ : /다음 문제/,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const table = within(screen.getByRole("table", { name: "난이도별 결과" }));
|
||||
expect
|
||||
.soft(table.queryByRole("columnheader", { name: "SNR" }))
|
||||
.toBeInTheDocument();
|
||||
for (const [label] of levels) {
|
||||
expect
|
||||
.soft(table.queryByRole("cell", { name: label }))
|
||||
.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
it("한글 조합 도중 처음으로 돌아가도 새 세션의 입력은 정상 제출된다", async () => {
|
||||
const f = fixture();
|
||||
await f.user.click(screen.getByRole("radio", { name: "직접 입력" }));
|
||||
await f.user.click(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
);
|
||||
await f.finish();
|
||||
fireEvent.compositionStart(
|
||||
screen.getByRole("textbox", { name: "들은 단어" }),
|
||||
);
|
||||
await f.user.click(screen.getByRole("button", { name: "처음으로" }));
|
||||
await f.user.click(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
);
|
||||
await f.finish();
|
||||
fireEvent.compositionStart(
|
||||
screen.getByRole("textbox", { name: "들은 단어" }),
|
||||
);
|
||||
await f.user.click(screen.getByRole("button", { name: /다시 듣기/ }));
|
||||
await f.finish();
|
||||
await f.user.type(
|
||||
screen.getByRole("textbox", { name: "들은 단어" }),
|
||||
createQuestions(43, "text")[0]!.word.answer,
|
||||
);
|
||||
await f.user.click(screen.getByRole("button", { name: "정답 확인" }));
|
||||
expect(screen.getByText("정답이에요!")).toBeInTheDocument();
|
||||
});
|
||||
it("한글 조합을 확정하는 Enter는 채점하지 않고 다음 제출을 기다린다", async () => {
|
||||
const f = fixture();
|
||||
await f.user.click(screen.getByRole("radio", { name: "직접 입력" }));
|
||||
await f.user.click(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
);
|
||||
await f.finish();
|
||||
const textbox = screen.getByRole("textbox", { name: "들은 단어" });
|
||||
fireEvent.compositionStart(textbox);
|
||||
fireEvent.change(textbox, {
|
||||
target: { value: createQuestions(42, "text")[0]!.word.answer },
|
||||
});
|
||||
fireEvent.submit(textbox.closest("form")!);
|
||||
expect(screen.queryByText("정답이에요!")).not.toBeInTheDocument();
|
||||
fireEvent.compositionEnd(textbox);
|
||||
fireEvent.submit(textbox.closest("form")!);
|
||||
expect(screen.getByText("정답이에요!")).toBeInTheDocument();
|
||||
});
|
||||
it("브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다", () => {
|
||||
const f = fixture();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "단어를 맞춰라" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Designed by OZ at Heat-T Medical Inc.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("v0.1")).toBeInTheDocument();
|
||||
expect(f.audio.play).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
it("재생 동안 답을 숨기고 중복 클릭을 막으며 끝나면 네 선택지에 접근할 수 있다", async () => {
|
||||
const f = fixture();
|
||||
await f.user.click(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
);
|
||||
expect(f.audio.play).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.queryByRole("group", { name: "답 선택" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(createQuestions(42, "mixed")[0]!.sentence),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /듣는 중/ })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: /듣는 중/ }));
|
||||
expect(f.audio.play).toHaveBeenCalledTimes(1);
|
||||
await f.finish();
|
||||
const group = screen.getByRole("group", { name: "답 선택" });
|
||||
expect(group.querySelectorAll("button")).toHaveLength(4);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: createQuestions(42, "mixed")[0]!.pattern.question,
|
||||
}),
|
||||
).toHaveFocus();
|
||||
await f.user.click(screen.getByRole("button", { name: /다시 듣기/ }));
|
||||
expect(f.audio.play).toHaveBeenCalledTimes(2);
|
||||
await f.finish();
|
||||
});
|
||||
it("혼합 모드 열 문제를 키보드·텍스트로 풀고 결과에서 다시 시작한다", async () => {
|
||||
const f = fixture();
|
||||
const questions = createQuestions(42, "mixed");
|
||||
await f.user.click(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
);
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
if (i)
|
||||
await f.user.click(screen.getByRole("button", { name: /문장 듣기/ }));
|
||||
await f.finish();
|
||||
const q = questions[i]!;
|
||||
if (q.mode === "choice") {
|
||||
screen.getByRole("button", { name: new RegExp(q.word.answer) }).focus();
|
||||
await f.user.keyboard("{Enter}");
|
||||
} else {
|
||||
await f.user.type(
|
||||
screen.getByRole("textbox", { name: "들은 단어" }),
|
||||
` ${q.word.answer} `,
|
||||
);
|
||||
await f.user.keyboard("{Enter}");
|
||||
}
|
||||
expect(screen.getByText("정답이에요!")).toBeInTheDocument();
|
||||
expect(screen.getByText(q.sentence)).toBeInTheDocument();
|
||||
await f.user.click(
|
||||
screen.getByRole("button", {
|
||||
name: i === 9 ? /결과 보기/ : /다음 문제/,
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /오늘의 소리 탐험/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("100%")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("table", { name: "난이도별 결과" }),
|
||||
).toBeInTheDocument();
|
||||
await f.user.click(screen.getByRole("button", { name: "다시 시작" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeEnabled();
|
||||
expect(f.audio.stop).toHaveBeenCalled();
|
||||
});
|
||||
it("오디오 오류를 알리고 재시도하며 나가면 장치를 정리한다", async () => {
|
||||
const f = fixture();
|
||||
await f.user.click(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
);
|
||||
await f.fail();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
/소리를 재생하지 못했어요/,
|
||||
);
|
||||
await f.user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(f.audio.play).toHaveBeenCalledTimes(2);
|
||||
await f.finish();
|
||||
f.unmount();
|
||||
expect(f.audio.dispose).toHaveBeenCalled();
|
||||
});
|
||||
it("선택한 주관식·배경 환경으로 시작하고 빈 답변을 제출하지 않는다", async () => {
|
||||
const f = fixture();
|
||||
await f.user.click(screen.getByRole("radio", { name: /직접 입력/ }));
|
||||
await f.user.selectOptions(screen.getByLabelText("배경 환경"), "rain");
|
||||
await f.user.click(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
);
|
||||
await f.finish();
|
||||
expect(f.audio.play).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "text",
|
||||
environment: expect.objectContaining({ id: "rain" }),
|
||||
}),
|
||||
true,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "정답 확인" })).toBeDisabled();
|
||||
await f.user.type(
|
||||
screen.getByRole("textbox", { name: "들은 단어" }),
|
||||
"<img src=x>",
|
||||
);
|
||||
await f.user.click(screen.getByRole("button", { name: "정답 확인" }));
|
||||
expect(screen.getByText("다음엔 찾을 수 있어요.")).toBeInTheDocument();
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
});
|
||||
it("음량 조절이 엔진에 전달되고 세션 종료 뒤 늦은 완료는 무시한다", async () => {
|
||||
const f = fixture();
|
||||
fireEvent.change(screen.getByRole("slider", { name: "전체 음량" }), {
|
||||
target: { value: "20" },
|
||||
});
|
||||
expect(f.audio.setVolume).toHaveBeenCalledWith(0.2);
|
||||
await f.user.click(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
);
|
||||
await f.user.click(screen.getByRole("button", { name: "처음으로" }));
|
||||
await f.finish();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeEnabled(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { INTRO, PATTERNS, WORDS } from "../src/content";
|
||||
|
||||
// 생성 바이너리를 눈으로 검토할 수 없으므로 문장·해시·PCM 포맷·실제 신호를
|
||||
// 함께 확인한다. manifest는 생성기가 남기며 100개 조합과 안내를 빠짐없이 묶는다.
|
||||
describe("저장한 한국어 음성 자산", () => {
|
||||
it("실제 생성 도구 버전을 기록하여 재생성의 출처를 추적한다", () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync("public/audio/manifest.json", "utf8"),
|
||||
) as { toolVersions?: { espeak: string; ffmpeg: string } };
|
||||
expect(manifest.toolVersions?.espeak).toMatch(/eSpeak NG.*\d+\.\d+/);
|
||||
expect(manifest.toolVersions?.ffmpeg).toMatch(/ffmpeg version \d+\.\d+/);
|
||||
});
|
||||
it("모든 문장과 안내에 대응하는 로컬 WAV·출처·해시를 제공한다", () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync("public/audio/manifest.json", "utf8"),
|
||||
) as {
|
||||
generator: string;
|
||||
files: { file: string; text: string; sha256: string }[];
|
||||
};
|
||||
expect(manifest.generator).toContain("eSpeak NG");
|
||||
const expected = [
|
||||
{ file: "intro.wav", text: INTRO },
|
||||
...PATTERNS.flatMap((p) =>
|
||||
WORDS.map((w) => ({
|
||||
file: `${p.id}-${w.id}.wav`,
|
||||
text: p.sentence
|
||||
.replaceAll("{word}", w.answer)
|
||||
.replaceAll("{distractor1}", w.distractors[0]!.word)
|
||||
.replaceAll("{distractor2}", w.distractors[1]!.word),
|
||||
})),
|
||||
),
|
||||
];
|
||||
expect(manifest.files.map(({ file, text }) => ({ file, text }))).toEqual(
|
||||
expected,
|
||||
);
|
||||
expect(
|
||||
readdirSync("public/audio").filter((f) => f.endsWith(".wav")),
|
||||
).toHaveLength(101);
|
||||
let size = 0;
|
||||
for (const entry of manifest.files) {
|
||||
const file = readFileSync(`public/audio/${entry.file}`);
|
||||
size += file.length;
|
||||
expect(createHash("sha256").update(file).digest("hex")).toBe(
|
||||
entry.sha256,
|
||||
);
|
||||
expect(file.toString("ascii", 0, 4)).toBe("RIFF");
|
||||
expect(file.toString("ascii", 8, 12)).toBe("WAVE");
|
||||
expect(file.readUInt16LE(22)).toBe(1);
|
||||
expect(file.readUInt32LE(24)).toBe(16000);
|
||||
expect(file.readUInt16LE(34)).toBe(16);
|
||||
expect(file.length).toBeGreaterThan(16000);
|
||||
const values = Array.from({ length: (file.length - 44) / 2 }, (_, i) =>
|
||||
file.readInt16LE(44 + i * 2),
|
||||
);
|
||||
const rms = Math.sqrt(
|
||||
values.reduce((sum, x) => sum + x * x, 0) / values.length,
|
||||
);
|
||||
expect(rms).toBeGreaterThan(100);
|
||||
// RMS 정규화 후 최고 난이도 아닌 가장 큰 +6dB에서도 클리핑 여유를 확인한다.
|
||||
expect(
|
||||
(values.reduce((peak, x) => Math.max(peak, Math.abs(x)), 0) / rms) *
|
||||
0.03 *
|
||||
2,
|
||||
).toBeLessThan(0.8);
|
||||
}
|
||||
expect(size).toBeLessThan(20 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AudioEngine } from "../src/audio/engine";
|
||||
import { createQuestions } from "../src/game";
|
||||
|
||||
// 녹음 재생 성공만 흉내 내는 대신 Web Audio 그래프의 노드·예약 시간·정리
|
||||
// 호출을 기록한다. HTTP와 장치 경계를 주입하여 경합을 결정론적으로 재현한다.
|
||||
function fixture() {
|
||||
const param = () => ({
|
||||
value: 1,
|
||||
setValueAtTime: vi.fn(),
|
||||
linearRampToValueAtTime: vi.fn(),
|
||||
setTargetAtTime: vi.fn(),
|
||||
});
|
||||
const node = () => ({ connect: vi.fn(), disconnect: vi.fn() });
|
||||
const gains: ReturnType<typeof makeGain>[] = [];
|
||||
function makeGain(): ReturnType<typeof node> & {
|
||||
gain: ReturnType<typeof param>;
|
||||
} {
|
||||
const gain = { ...node(), gain: param() };
|
||||
gains.push(gain);
|
||||
return gain;
|
||||
}
|
||||
function buffer(length = 16000, rate = 16000) {
|
||||
const data = Float32Array.from(
|
||||
{ length },
|
||||
(_, i) => 0.2 * Math.sin(i / 20),
|
||||
);
|
||||
return {
|
||||
duration: length / rate,
|
||||
length,
|
||||
sampleRate: rate,
|
||||
numberOfChannels: 1,
|
||||
getChannelData: () => data,
|
||||
copyToChannel: vi.fn(),
|
||||
};
|
||||
}
|
||||
const sources: ReturnType<typeof makeSource>[] = [];
|
||||
function makeSource(): ReturnType<typeof node> & {
|
||||
buffer: AudioBuffer | null;
|
||||
loop: boolean;
|
||||
start: ReturnType<typeof vi.fn<(when?: number) => void>>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
onended: null | (() => void);
|
||||
} {
|
||||
const source = {
|
||||
...node(),
|
||||
buffer: null,
|
||||
loop: false,
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
onended: null as null | (() => void),
|
||||
};
|
||||
sources.push(source);
|
||||
return source;
|
||||
}
|
||||
const context = {
|
||||
currentTime: 2,
|
||||
sampleRate: 16000,
|
||||
state: "suspended",
|
||||
destination: {},
|
||||
resume: vi.fn(async () => {
|
||||
context.state = "running";
|
||||
}),
|
||||
close: vi.fn(async () => {}),
|
||||
createGain: makeGain,
|
||||
createBufferSource: makeSource,
|
||||
createBuffer: (_channels: number, length: number, rate: number) =>
|
||||
buffer(length, rate),
|
||||
decodeAudioData: vi.fn(async () => buffer()),
|
||||
};
|
||||
const fetcher = vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }) as Response,
|
||||
);
|
||||
const factory = vi.fn(() => context as unknown as AudioContext);
|
||||
return {
|
||||
engine: new AudioEngine(factory, fetcher),
|
||||
context,
|
||||
sources,
|
||||
gains,
|
||||
fetcher,
|
||||
factory,
|
||||
};
|
||||
}
|
||||
const question = createQuestions(42, "choice")[0]!;
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 12; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
describe("오디오 재생 수명과 믹서", () => {
|
||||
it("생성 시 무음이며 gesture의 play에서만 장치를 연다", async () => {
|
||||
const f = fixture();
|
||||
expect(f.factory).not.toHaveBeenCalled();
|
||||
const started = vi.fn();
|
||||
const done = f.engine.play(question, true, started);
|
||||
expect(f.factory).toHaveBeenCalledOnce();
|
||||
expect(f.context.resume).toHaveBeenCalledOnce();
|
||||
await flush();
|
||||
expect(started).toHaveBeenCalledOnce();
|
||||
expect(f.sources).toHaveLength(3);
|
||||
expect(f.fetcher.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/audio/intro.wav",
|
||||
question.audioUrl,
|
||||
]);
|
||||
// master, 배경, 목표, 안내의 gain을 분리한다. 안내는 점차 소음에 묻힌다.
|
||||
expect(f.gains[1]!.gain.setValueAtTime).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(f.gains[2]!.gain.setValueAtTime).toHaveBeenCalledWith(
|
||||
10 ** (question.db / 20),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(f.gains[3]!.gain.linearRampToValueAtTime).toHaveBeenCalledWith(
|
||||
0.12,
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(f.sources[2]!.start.mock.calls[0]![0]!).toBeGreaterThan(
|
||||
f.sources[1]!.start.mock.calls[0]![0]!,
|
||||
);
|
||||
f.sources[2]!.onended?.();
|
||||
await done;
|
||||
expect(f.sources.every((s) => s.disconnect.mock.calls.length > 0)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("동시 시작을 거부하고 정지하면 모든 노드와 대기 Promise를 취소한다", async () => {
|
||||
const f = fixture();
|
||||
const done = f.engine.play(question, false, vi.fn());
|
||||
const cancelled = expect(done).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
|
||||
"재생 중",
|
||||
);
|
||||
await flush();
|
||||
f.engine.stop();
|
||||
await cancelled;
|
||||
expect(f.sources.every((s) => s.stop.mock.calls.length > 0)).toBe(true);
|
||||
expect(f.sources.every((s) => s.onended === null)).toBe(true);
|
||||
f.engine.dispose();
|
||||
expect(f.context.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("로드 도중 취소한 이전 작업이 늦게 완료되어도 소리를 시작하지 않는다", async () => {
|
||||
const f = fixture();
|
||||
let release!: (value: Response) => void;
|
||||
f.fetcher.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const done = f.engine.play(question, false, vi.fn());
|
||||
const cancelled = expect(done).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
await flush();
|
||||
f.engine.stop();
|
||||
await cancelled;
|
||||
release({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
await flush();
|
||||
expect(f.sources).toHaveLength(0);
|
||||
});
|
||||
it("HTTP·디코드·장치 실패를 전달하고 다음 시도는 다시 로드한다", async () => {
|
||||
const f = fixture();
|
||||
f.fetcher.mockResolvedValueOnce({ ok: false } as Response);
|
||||
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
|
||||
"음성 파일",
|
||||
);
|
||||
f.context.decodeAudioData.mockRejectedValueOnce(new Error("decode"));
|
||||
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
|
||||
"decode",
|
||||
);
|
||||
f.context.resume.mockRejectedValueOnce(new Error("권한"));
|
||||
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
|
||||
"권한",
|
||||
);
|
||||
const done = f.engine.play(question, false, vi.fn());
|
||||
await flush();
|
||||
f.sources.at(-1)!.onended?.();
|
||||
await done;
|
||||
});
|
||||
it("전체 음량은 두 신호의 비율을 보존하며 0부터 1 범위로 제한한다", async () => {
|
||||
const f = fixture();
|
||||
f.engine.setVolume(0.3);
|
||||
const done = f.engine.play(question, false, vi.fn());
|
||||
await flush();
|
||||
expect(f.gains[0]!.gain.value).toBe(0.3);
|
||||
f.engine.setVolume(8);
|
||||
expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(1, 2, 0.02);
|
||||
f.engine.setVolume(-1);
|
||||
expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(0, 2, 0.02);
|
||||
f.sources.at(-1)!.onended?.();
|
||||
await done;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { WORDS } from "../../src/content";
|
||||
|
||||
// 실제 WAV를 디코드하고 Web Audio의 ended를 기다린다. 모의 엔진을 사용한
|
||||
// 컴포넌트 검증과 달리 배포 산출물·자동 재생 정책·레이아웃을 함께 확인한다.
|
||||
test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
const external: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let audioPath = "";
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().startsWith("http://127.0.0.1:4173"))
|
||||
external.push(request.url());
|
||||
if (
|
||||
request.url().endsWith(".wav") &&
|
||||
!request.url().endsWith("intro.wav")
|
||||
)
|
||||
audioPath = new URL(request.url()).pathname;
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "단어를 맞춰라" }),
|
||||
).toBeVisible();
|
||||
expect(audioPath).toBe("");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-home.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual(
|
||||
[],
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page
|
||||
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /듣는 중/ }),
|
||||
).toBeDisabled();
|
||||
await expect(page.locator(".sound-wave.active i").first()).toHaveCSS(
|
||||
"animation-name",
|
||||
"none",
|
||||
);
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (i) await page.getByRole("button", { name: /문장 듣기/ }).click();
|
||||
const target =
|
||||
i % 2
|
||||
? page.getByRole("textbox", { name: "들은 단어" })
|
||||
: page.getByRole("group", { name: "답 선택" });
|
||||
await expect(target).toBeVisible();
|
||||
// 열 가지 질문 모두 실제 렌더링을 거쳐 어절 보존과 패널 내 표시를 검증한다.
|
||||
const heading = page.locator(".question-panel h2");
|
||||
await expect(heading).toHaveCSS("word-break", "keep-all");
|
||||
expect(
|
||||
await heading.evaluate(
|
||||
(element) => element.scrollWidth <= element.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
const answer = WORDS.find((w) =>
|
||||
audioPath.endsWith(`-${w.id}.wav`),
|
||||
)!.answer;
|
||||
if (i === 0) {
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-game.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
expect(
|
||||
(await new AxeBuilder({ page }).analyze()).violations,
|
||||
).toEqual([]);
|
||||
await page.getByRole("button", { name: /다시 듣기/ }).click();
|
||||
await expect(target).toBeVisible();
|
||||
}
|
||||
if (i % 2) {
|
||||
await target.fill(answer);
|
||||
await target.press("Enter");
|
||||
} else {
|
||||
const button = page.getByRole("button", {
|
||||
name: new RegExp(answer),
|
||||
});
|
||||
await button.focus();
|
||||
await button.press("Enter");
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "정답이에요!" }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: i === 9 ? /결과 보기/ : /다음 문제/ })
|
||||
.click();
|
||||
}
|
||||
await expect(page.getByText("100%")).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-results.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual(
|
||||
[],
|
||||
);
|
||||
await page.getByRole("button", { name: "다시 시작" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("오디오 HTTP 실패를 화면에 표시하고 재시도한다", async ({ page }) => {
|
||||
await page.route("**/audio/*.wav", (route) =>
|
||||
route.fulfill({ status: 404, body: "missing" }),
|
||||
);
|
||||
await page.goto("/");
|
||||
await page
|
||||
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
|
||||
.click();
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"소리를 재생하지 못했어요",
|
||||
);
|
||||
await page.unroute("**/audio/*.wav");
|
||||
await page.getByRole("button", { name: "다시 시도" }).click();
|
||||
// 재시도는 안내부터 재생한다. 최대 8.1205 + 10.1725 + 시작·전환 0.4초에
|
||||
// 로드·디코드·병렬 브라우저 스케줄링 여유를 더해 이 assertion만 30초로 제한한다.
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "처음으로" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// 소스의 사본만 확인하면 public 파일이 배포에서 빠진 경우를 놓친다.
|
||||
// 실제 production preview가 원문 크레딧과 notice 전문을 제공하는지 확인한다.
|
||||
test("릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공한다", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await expect
|
||||
.soft(
|
||||
page.getByText("Designed by OZ at Heat-T Medical Inc.", { exact: true }),
|
||||
)
|
||||
.toBeVisible({ timeout: 1000 });
|
||||
const response = await request.get("/THIRD_PARTY_NOTICES.md");
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(await response.text()).toBe(
|
||||
readFileSync("THIRD_PARTY_NOTICES.md", "utf8"),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
import { createQuestions } from "../../src/game";
|
||||
|
||||
test("전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const volume = page.getByRole("slider", { name: "전체 음량" });
|
||||
// 문서상 소속뿐 아니라 컨트롤 전체가 카드 경계 안에 그려지는지 검증한다.
|
||||
const expectInside = async (panel: Locator) => {
|
||||
const bounds = (await panel.boundingBox())!;
|
||||
const control = (await page.locator(".volume-row").boundingBox())!;
|
||||
expect.soft(control.x).toBeGreaterThanOrEqual(bounds.x);
|
||||
expect.soft(control.y).toBeGreaterThanOrEqual(bounds.y);
|
||||
expect
|
||||
.soft(control.x + control.width)
|
||||
.toBeLessThanOrEqual(bounds.x + bounds.width);
|
||||
expect
|
||||
.soft(control.y + control.height)
|
||||
.toBeLessThanOrEqual(bounds.y + bounds.height);
|
||||
};
|
||||
await expectInside(
|
||||
page.getByRole("region", { name: "나의 소리 탐험 준비" }),
|
||||
);
|
||||
const start = page.getByRole("button", {
|
||||
name: "게임 시작 · 소리 듣기",
|
||||
});
|
||||
const control = (await page.locator(".volume-row").boundingBox())!;
|
||||
expect
|
||||
.soft(control.y + control.height)
|
||||
.toBeLessThanOrEqual((await start.boundingBox())!.y);
|
||||
await volume.focus();
|
||||
await volume.press("ArrowRight");
|
||||
await expect(volume).toHaveValue("36");
|
||||
await volume.press("Tab");
|
||||
await expect(start).toBeFocused();
|
||||
await start.click();
|
||||
await expectInside(page.getByRole("complementary"));
|
||||
await expect(volume).toHaveValue("36");
|
||||
await volume.focus();
|
||||
await volume.press("ArrowLeft");
|
||||
await expect(volume).toHaveValue("35");
|
||||
// 음량을 패널로 옮겨도 카드와 바닥글이 맞붙지 않도록 기존 화면 구분을 유지한다.
|
||||
const cards = (await page.locator(".game-columns").boundingBox())!;
|
||||
const footer = (await page.getByRole("contentinfo").boundingBox())!;
|
||||
expect(footer.y - (cards.y + cards.height)).toBeGreaterThanOrEqual(24);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("난이도 설명은 모바일에서도 보인다", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page
|
||||
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
|
||||
.click();
|
||||
// DOM에만 설명을 남기고 모바일 CSS로 숨기면 컴포넌트 검증은 통과하므로
|
||||
// 실제 화면에서 난이도 배지와 쉬운 설명을 모두 읽을 수 있는지 확인한다.
|
||||
await expect
|
||||
.soft(page.locator(".db-note"))
|
||||
.toBeVisible({ timeout: 1000 });
|
||||
await expect
|
||||
.soft(page.getByText("1단계 · SNR +6 dB"))
|
||||
.toBeVisible({ timeout: 1000 });
|
||||
await expect
|
||||
.soft(page.locator(".db-note"))
|
||||
.toContainText("음성이 배경보다 6 dB 커요.", { timeout: 1000 });
|
||||
});
|
||||
|
||||
// jsdom은 줄바꿈을 계산하지 않는다. 실제 Chrome에서 한국어 제목의
|
||||
// computed style을 확인하고 360px에서도 질문과 페이지가 넘치지 않게 한다.
|
||||
test("한국어 제목은 단어 중간에서 줄바꿈하지 않는다", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await page.addInitScript(() => {
|
||||
Date.now = () => 42;
|
||||
});
|
||||
await page.goto("/");
|
||||
const wordBreaks = () =>
|
||||
page.getByRole("heading").evaluateAll((headings) =>
|
||||
headings.map((heading) => ({
|
||||
title: heading.textContent,
|
||||
wordBreak: getComputedStyle(heading).wordBreak,
|
||||
})),
|
||||
);
|
||||
const homeHeadings = await wordBreaks();
|
||||
expect
|
||||
.soft(homeHeadings)
|
||||
.toEqual(
|
||||
homeHeadings.map((heading) => ({
|
||||
...heading,
|
||||
wordBreak: "keep-all",
|
||||
})),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
|
||||
.click();
|
||||
const question = page.getByRole("heading", {
|
||||
name: createQuestions(42, "mixed")[0]!.pattern.question,
|
||||
});
|
||||
await expect(question).toBeVisible();
|
||||
// 전체 화면 캡처 전에 스크롤을 맞춰 화면 밖의 fixed 건너뛰기 링크가
|
||||
// 이미지 안으로 들어오는 Chrome 캡처 아티팩트를 방지한다. 초점은 유지한다.
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-heading.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
const gameHeadings = await wordBreaks();
|
||||
expect
|
||||
.soft(gameHeadings)
|
||||
.toEqual(
|
||||
gameHeadings.map((heading) => ({
|
||||
...heading,
|
||||
wordBreak: "keep-all",
|
||||
})),
|
||||
);
|
||||
expect(
|
||||
await question.evaluate(
|
||||
(heading) => heading.scrollWidth <= heading.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// 실제 배포는 하지 않는다. worktree 내부 임시 폴더에 public 자산만 복사한 뒤
|
||||
// 정상·누락·변조를 검사하여 빌드 검증기가 실패를 정말 감지하는지 먼저 확인한다.
|
||||
describe("production 자산 검증기", () => {
|
||||
it("101개 음성 포함을 확인하고 누락·변조는 실패시킨다", () => {
|
||||
mkdirSync(".cache", { recursive: true });
|
||||
const temporary = mkdtempSync(".cache/build-test-");
|
||||
const verify = () =>
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import {verifyBuild} from './scripts/check-build.mjs'; console.log(verifyBuild(${JSON.stringify(temporary)}));`,
|
||||
],
|
||||
{ encoding: "utf8", stdio: "pipe" },
|
||||
);
|
||||
try {
|
||||
cpSync("public", temporary, { recursive: true });
|
||||
expect(verify().trim()).toBe("101");
|
||||
writeFileSync(join(temporary, "audio/intro.wav"), "broken");
|
||||
expect(verify).toThrow();
|
||||
rmSync(join(temporary, "audio/intro.wav"));
|
||||
expect(verify).toThrow();
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it.each(["누락", "축약"])(
|
||||
"dist의 런타임 notice %s을 실패시킨다",
|
||||
(damage) => {
|
||||
mkdirSync(".cache", { recursive: true });
|
||||
const temporary = mkdtempSync(".cache/notice-test-");
|
||||
const verify = () =>
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import {verifyBuild} from './scripts/check-build.mjs'; console.log(verifyBuild(${JSON.stringify(temporary)}));`,
|
||||
],
|
||||
{ encoding: "utf8", stdio: "pipe" },
|
||||
);
|
||||
try {
|
||||
cpSync("public", temporary, { recursive: true });
|
||||
expect(verify().trim()).toBe("101");
|
||||
const notice = join(temporary, "THIRD_PARTY_NOTICES.md");
|
||||
if (damage === "누락") rmSync(notice, { force: true });
|
||||
else
|
||||
writeFileSync(notice, "React / ReactDOM / scheduler — MIT License");
|
||||
expect(verify).toThrow(/notice/i);
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
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.seed)).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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ENVIRONMENTS, LEVELS } from "../src/content";
|
||||
import { dbToGain, normalizeRms, synthesizeBackground } from "../src/audio/dsp";
|
||||
|
||||
// 청감이나 실제 음압은 장치마다 다르므로 테스트 대상은 디지털 RMS와 gain
|
||||
// 비율이다. 배경·음성을 같은 기준으로 정규화해야 표기한 상대 dB가 성립한다.
|
||||
const rms = (a: Float32Array) =>
|
||||
Math.sqrt(a.reduce((sum, n) => sum + n * n, 0) / a.length);
|
||||
describe("상대 dB와 결정론적 배경 합성", () => {
|
||||
it("20 log10(음성 gain / 배경 gain)이 표시 dB와 일치한다", () => {
|
||||
for (const level of LEVELS)
|
||||
expect(20 * Math.log10(dbToGain(level.db))).toBeCloseTo(level.db, 10);
|
||||
expect(dbToGain(-6)).toBeCloseTo(0.501187, 5);
|
||||
});
|
||||
it("평균 전력을 맞추고 원본 배열을 변경하지 않으며 무음은 오류로 알린다", () => {
|
||||
const input = new Float32Array([0.4, -0.2, 0.1, -0.3]);
|
||||
const output = normalizeRms(input);
|
||||
expect(rms(output)).toBeCloseTo(0.12, 6);
|
||||
expect(output).not.toBe(input);
|
||||
expect(() => normalizeRms(new Float32Array(4))).toThrow("무음");
|
||||
});
|
||||
it("여덟 환경이 서로 다르고 같은 seed는 같은 안전 범위 신호를 만든다", () => {
|
||||
const signatures = new Set<string>();
|
||||
for (const preset of ENVIRONMENTS) {
|
||||
const signal = synthesizeBackground(preset, 16000, 1);
|
||||
expect(signal).toHaveLength(16000);
|
||||
expect(signal).toEqual(synthesizeBackground(preset, 16000, 1));
|
||||
expect(rms(signal)).toBeCloseTo(0.12, 5);
|
||||
expect(Math.max(...signal.map(Math.abs))).toBeLessThan(1);
|
||||
// 루프 경계에서 불연속 클릭이 생기지 않도록 양 끝을 0으로 감싼다.
|
||||
expect(signal[0]).toBe(0);
|
||||
expect(signal.at(-1)).toBe(0);
|
||||
signatures.add([...signal.slice(0, 100)].join(","));
|
||||
}
|
||||
expect(signatures.size).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createQuestions,
|
||||
gameReducer,
|
||||
initialGame,
|
||||
isCorrect,
|
||||
normalizeAnswer,
|
||||
summarize,
|
||||
} from "../src/game";
|
||||
|
||||
// 고정 seed로 재현 가능성을 확보하되 seed를 바꾸면 10문제를 넘는 조합이
|
||||
// 실제로 선택되는지 본다. 오디오 URL도 같은 문장 조합을 가리켜야 한다.
|
||||
describe("출제와 정답 판정", () => {
|
||||
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}");
|
||||
expect(q.audioUrl).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(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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// args에서 플래그만 지워도 Playwright 기본값이 sandbox를 끌 수 있다.
|
||||
// 실제 설정을 새 프로세스로 읽어 환경변수 캐시와 개발자 환경에 영향받지 않는다.
|
||||
function launchOptions(optIn?: string) {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
CHROME_PATH: "/usr/bin/google-chrome",
|
||||
};
|
||||
delete env.PLAYWRIGHT_NO_SANDBOX;
|
||||
if (optIn !== undefined) env.PLAYWRIGHT_NO_SANDBOX = optIn;
|
||||
return JSON.parse(
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--experimental-strip-types",
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"import config from './playwright.config.ts'; console.log(JSON.stringify(config.use.launchOptions));",
|
||||
],
|
||||
{ encoding: "utf8", env },
|
||||
),
|
||||
) as {
|
||||
chromiumSandbox?: boolean;
|
||||
args?: string[];
|
||||
executablePath?: string;
|
||||
};
|
||||
}
|
||||
|
||||
describe("브라우저 sandbox 기본 정책", () => {
|
||||
it.each([undefined, "", "0", "false"])(
|
||||
"opt-in=%s이면 sandbox를 켠다",
|
||||
(value) => {
|
||||
const options = launchOptions(value);
|
||||
expect.soft(options.chromiumSandbox).toBe(true);
|
||||
expect.soft(options.args ?? []).not.toContain("--no-sandbox");
|
||||
expect(options.executablePath).toBe("/usr/bin/google-chrome");
|
||||
},
|
||||
);
|
||||
it("명시적인 PLAYWRIGHT_NO_SANDBOX=1에서만 sandbox를 끈다", () => {
|
||||
expect(launchOptions("1").chromiumSandbox).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// 사용자 지정 문자열은 편집자가 회사 이름을 추측해 정규화하지 못하도록
|
||||
// 화면 테스트와 문서 계약에서 원문 그대로 고정한다.
|
||||
describe("릴리스 문서 계약", () => {
|
||||
it("Node 최소 버전은 잠긴 의존성이 요구하는 22.22.2와 일치한다", () => {
|
||||
const metadata = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
|
||||
expect.soft(metadata.engines.node).toBe(">=22.22.2");
|
||||
expect.soft(lock.packages[""].engines.node).toBe(">=22.22.2");
|
||||
expect(lock.packages["node_modules/jsdom"].engines.node).toContain(
|
||||
"^22.22.2",
|
||||
);
|
||||
for (const path of ["README.md", "docs/DEPLOYMENT.md"]) {
|
||||
const document = readFileSync(path, "utf8");
|
||||
expect.soft(document, path).toContain("22.22.2");
|
||||
expect.soft(document, path).not.toContain("22.12");
|
||||
}
|
||||
});
|
||||
it("프로젝트는 비공개 권리 유보이며 저장소 주소를 명시한다", () => {
|
||||
const metadata = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
|
||||
expect.soft(metadata.private).toBe(true);
|
||||
expect.soft(metadata.license).toBe("UNLICENSED");
|
||||
expect.soft(lock.packages[""].license).toBe("UNLICENSED");
|
||||
expect.soft(metadata.repository).toEqual({
|
||||
type: "git",
|
||||
url: "https://source.ibit.kr/OZ-Group/Find-Word.git",
|
||||
});
|
||||
expect
|
||||
.soft(metadata.homepage)
|
||||
.toBe("https://source.ibit.kr/OZ-Group/Find-Word");
|
||||
const license = readFileSync("LICENSE", "utf8");
|
||||
expect(license).toMatch(/private/i);
|
||||
expect(license).toContain("All rights reserved.");
|
||||
expect(readFileSync("README.md", "utf8")).toContain(metadata.homepage);
|
||||
});
|
||||
it("잠금 파일의 모든 런타임 의존성 notice 전문을 배포용 사본에 보존한다", () => {
|
||||
const lock = JSON.parse(readFileSync("package-lock.json", "utf8")) as {
|
||||
packages: Record<string, { dev?: boolean; version: string }>;
|
||||
};
|
||||
const notice = readFileSync("THIRD_PARTY_NOTICES.md", "utf8");
|
||||
expect(readFileSync("public/THIRD_PARTY_NOTICES.md", "utf8")).toBe(notice);
|
||||
for (const [path, pkg] of Object.entries(lock.packages)) {
|
||||
if (!path || pkg.dev) continue;
|
||||
const name = path.split("node_modules/").at(-1)!;
|
||||
const license = readFileSync(`${path}/LICENSE`, "utf8").trim();
|
||||
expect(notice).toContain(`## ${name} ${pkg.version}\n\n${license}`);
|
||||
}
|
||||
});
|
||||
it("브랜드 문구를 사용자 원문 그대로 문서에도 보존한다", () => {
|
||||
const credit = "Designed by OZ at Heat-T Medical Inc.";
|
||||
for (const path of [
|
||||
"README.md",
|
||||
".agent/TASK.md",
|
||||
".agent/ACCEPTANCE.md",
|
||||
".agent/CONTEXT.md",
|
||||
]) {
|
||||
expect.soft(readFileSync(path, "utf8"), path).toContain(credit);
|
||||
}
|
||||
for (const path of [
|
||||
"README.md",
|
||||
...["docs", ".agent"].flatMap((dir) =>
|
||||
readdirSync(dir)
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.map((file) => `${dir}/${file}`),
|
||||
),
|
||||
]) {
|
||||
// 잘못된 표기를 다시 권장하는 문서가 남아 있으면 다음 편집 때 회귀한다.
|
||||
expect
|
||||
.soft(readFileSync(path, "utf8"), path)
|
||||
.not.toContain(["HearT", "M"].join("-"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
// 각 시나리오의 DOM과 이벤트 구독을 제거하여 이전 문제의 상태가 다음
|
||||
// 테스트에 누출되는 일을 막는다. 오디오 성공 여부는 테스트가 직접 제어한다.
|
||||
afterEach(cleanup);
|
||||
Reference in New Issue
Block a user