Files

564 lines
22 KiB
TypeScript

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(startImmediately = true) {
let resolve!: () => void;
let reject!: (reason: Error) => void;
const audio: AudioPort = {
play: vi.fn((_q, _intro, started) => {
if (startImmediately) 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: "문제 풀이 종료" }));
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[0]!;
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 === questions.length - 1 ? /결과 보기/ : /다음 문제/,
}),
);
}
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.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.2.5")).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 === questions.length - 1 ? /결과 보기/ : /다음 문제/,
}),
);
}
expect(
screen.getByRole("heading", { name: /1단계 문제 풀이를 마쳤어요/ }),
).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.user.click(screen.getByRole("button", { name: "처음 화면으로" }));
await f.finish();
await waitFor(() =>
expect(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
).toBeEnabled(),
);
});
});
// 새 라운드의 시작 단계와 수동 SNR은 서로 독립적이다. 다음 단계에서도 명시적
// SNR은 유지하지만 홈으로 돌아온 뒤에는 그 단계의 기본값으로 초기화한다.
it("시작 단계·SNR 선택, 재청취, 남은 문제와 다음 단계 라운드에 설정을 적용한다", async () => {
const f = fixture();
await f.user.click(screen.getByRole("radio", { name: "보기에서 선택" }));
await f.user.selectOptions(screen.getByLabelText("시작 난이도"), "4");
expect(screen.getByRole("slider", { name: "목표 음성 SNR" })).toHaveValue(
"-3",
);
fireEvent.change(screen.getByRole("slider", { name: "목표 음성 SNR" }), {
target: { value: "-8" },
});
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
expect(f.audio.play).toHaveBeenLastCalledWith(
expect.objectContaining({ level: 4, db: -8 }),
true,
expect.any(Function),
);
fireEvent.change(screen.getByRole("slider", { name: "목표 음성 SNR" }), {
target: { value: "2" },
});
await f.finish();
await f.user.click(screen.getByRole("button", { name: /다시 듣기/ }));
expect(f.audio.play).toHaveBeenLastCalledWith(
expect.objectContaining({ db: 2 }),
false,
expect.any(Function),
);
await f.finish();
for (let i = 0; i < 3; i++) {
if (i) {
await f.user.click(screen.getByRole("button", { name: /문장 듣기/ }));
await f.finish();
}
const question = vi.mocked(f.audio.play).mock.calls.at(-1)![0];
expect(question).toMatchObject({ level: 4, db: 2 });
await f.user.click(
screen.getByRole("button", { name: new RegExp(question.word.answer) }),
);
await f.user.click(
screen.getByRole("button", { name: i === 2 ? /결과 보기/ : /다음 문제/ }),
);
}
expect(screen.getByText("정답률 · 3/3")).toBeInTheDocument();
await f.user.click(
screen.getByRole("button", { name: "다음 난이도 · 5단계" }),
);
expect(f.audio.play).toHaveBeenLastCalledWith(
expect.objectContaining({ level: 5, db: 2 }),
true,
expect.any(Function),
);
await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" }));
expect(screen.getByText("응답 0문항 · 정답 0문항")).toBeInTheDocument();
await f.user.click(screen.getByRole("button", { name: "처음 화면으로" }));
expect(screen.getByRole("slider", { name: "목표 음성 SNR" })).toHaveValue(
"-3",
);
await f.finish();
});
// loading과 playing에서 취소할 때 Promise가 늦게 완료되어도 결과/홈을 지킨다.
it.each([false, true])(
"오디오 started=%s 중 종료는 즉시 정지하고 결과를 거쳐 홈으로 간다",
async (started) => {
const f = fixture(started);
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" }));
expect(f.audio.stop).toHaveBeenCalledOnce();
expect(
screen.getByRole("heading", { name: "문제 풀이를 중간에 종료했어요." }),
).toHaveFocus();
expect(screen.getByText("응답 0문항 · 정답 0문항")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /다음 난이도/ }),
).not.toBeInTheDocument();
await f.finish();
expect(
screen.getByRole("heading", { name: "문제 풀이를 중간에 종료했어요." }),
).toBeInTheDocument();
await f.user.click(screen.getByRole("button", { name: "처음 화면으로" }));
expect(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
).toBeEnabled();
},
);
it("답변 피드백에서 종료하면 답한 한 문항만 집계한다", async () => {
const f = fixture();
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.finish();
await f.user.click(
screen.getByRole("button", {
name: new RegExp(createQuestions(42, "mixed")[0]!.word.answer),
}),
);
await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" }));
expect(screen.getByText("응답 1문항 · 정답 1문항")).toBeInTheDocument();
expect(screen.getByText("정답률 · 1/1")).toBeInTheDocument();
});
it("5단계 완료는 6단계 대신 같은 단계의 새 세 문항을 시작한다", async () => {
const f = fixture();
await f.user.selectOptions(screen.getByLabelText("시작 난이도"), "5");
await f.user.click(screen.getByRole("radio", { name: "보기에서 선택" }));
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
for (let i = 0; i < 3; i++) {
if (i)
await f.user.click(screen.getByRole("button", { name: /문장 듣기/ }));
await f.finish();
const q = vi.mocked(f.audio.play).mock.calls.at(-1)![0];
await f.user.click(
screen.getByRole("button", { name: new RegExp(q.word.answer) }),
);
await f.user.click(
screen.getByRole("button", { name: i === 2 ? /결과 보기/ : /다음 문제/ }),
);
}
expect(
screen.queryByRole("button", { name: /다음 난이도/ }),
).not.toBeInTheDocument();
await f.user.click(screen.getByRole("button", { name: "5단계 다시 도전" }));
expect(f.audio.play).toHaveBeenLastCalledWith(
expect.objectContaining({ level: 5, db: -6 }),
true,
expect.any(Function),
);
expect(screen.getByRole("progressbar")).toHaveAttribute("max", "3");
expect(screen.getByRole("progressbar")).toHaveAttribute("value", "1");
await f.finish();
});
it("환경음 저작자·라이선스 고지를 같은 출처 링크로 제공한다", () => {
fixture();
expect(
screen.getByRole("link", { name: "환경음 출처·라이선스" }),
).toHaveAttribute("href", "/ambience/AMBIENCE_NOTICES.md");
});
it("결과 헤더에 존재하지 않는 플레이 방법 앵커를 표시하지 않는다", async () => {
const f = fixture();
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" }));
expect(
screen.queryByRole("link", { name: "플레이 방법" }),
).not.toBeInTheDocument();
await f.finish();
});
it("세 문항 라운드에서 가능한 연속 정답 보너스 상한을 설명한다", async () => {
const f = fixture();
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" }));
expect(
screen.getByText(
"정답마다 단계 × 100점 + 연속 정답 보너스 (문항별 최대 20점, 라운드 합계 최대 30점)",
),
).toBeInTheDocument();
await f.finish();
});
// 답변 전과 피드백 중에도 현재 문제 위치를 표시한다. 완주 점수와 안내를 함께 확인한다.
it("현재 문항 진행률은 1/3→2/3→3/3이고 1단계 만점은 330점이다", async () => {
const f = fixture();
await f.user.click(screen.getByRole("radio", { name: "보기에서 선택" }));
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
for (let index = 0; index < 3; index++) {
const bar = screen.getByRole("progressbar", { name: "현재 문항 진행률" });
expect(bar).toHaveAttribute("max", "3");
expect(bar).toHaveAttribute("value", String(index + 1));
expect(bar).toHaveAttribute("aria-valuetext", `${index + 1}/3문항`);
if (index)
await f.user.click(screen.getByRole("button", { name: /문장 듣기/ }));
await f.finish();
const q = vi.mocked(f.audio.play).mock.calls.at(-1)![0];
await f.user.click(
screen.getByRole("button", { name: new RegExp(q.word.answer) }),
);
expect(bar).toHaveAttribute("value", String(index + 1));
await f.user.click(
screen.getByRole("button", {
name: index === 2 ? /결과 보기/ : /다음 문제/,
}),
);
}
expect(
screen.getByRole("heading", { name: "1단계 문제 풀이를 마쳤어요." }),
).toBeInTheDocument();
expect(screen.getByText("330")).toBeInTheDocument();
expect(
screen.getByText(/문항별 최대 20점, 라운드 합계 최대 30점/),
).toBeInTheDocument();
});
it("무응답 종료는 정답률 대신 미응답 상태와 단계별 미진행을 보여 준다", async () => {
const f = fixture();
await f.user.click(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
);
await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" }));
expect(
screen.getByRole("heading", { name: "문제 풀이를 중간에 종료했어요." }),
).toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
expect(screen.getByText("정답률 · 응답 없음")).toBeInTheDocument();
expect(screen.getAllByRole("cell", { name: "미진행" })).toHaveLength(5);
expect(document.body.textContent).not.toMatch(/0%|0\s*\/\s*0/);
await f.finish();
});