337 lines
13 KiB
TypeScript
337 lines
13 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() {
|
|
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.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(),
|
|
);
|
|
});
|
|
});
|