feat: release Find-Word v0.2.3
This commit is contained in:
+239
-12
@@ -14,12 +14,12 @@ import { createQuestions } from "../src/game";
|
||||
|
||||
// 사용자가 보는 의미 역할로만 화면을 조작한다. 오디오 완료를 수동 제어하여
|
||||
// 재생 중에는 문제·선택지가 노출되지 않는지, 오류 뒤 재시도가 가능한지 본다.
|
||||
function fixture() {
|
||||
function fixture(startImmediately = true) {
|
||||
let resolve!: () => void;
|
||||
let reject!: (reason: Error) => void;
|
||||
const audio: AudioPort = {
|
||||
play: vi.fn((_q, _intro, started) => {
|
||||
started();
|
||||
if (startImmediately) started();
|
||||
return new Promise<void>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
@@ -96,7 +96,8 @@ describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
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: "문제 풀이 종료" }));
|
||||
await f.user.click(screen.getByRole("button", { name: "처음 화면으로" }));
|
||||
expect(screen.getByRole("slider", { name: "전체 음량" })).toHaveValue("60");
|
||||
});
|
||||
it("다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다", async () => {
|
||||
@@ -118,7 +119,7 @@ describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
if (index)
|
||||
await f.user.click(screen.getByRole("button", { name: /문장 듣기/ }));
|
||||
await f.finish();
|
||||
const [label, description] = levels[Math.floor(index / 2)]!;
|
||||
const [label, description] = levels[0]!;
|
||||
expect
|
||||
.soft(screen.queryByText(`${question.level}단계 · ${label}`))
|
||||
.toBeInTheDocument();
|
||||
@@ -132,7 +133,7 @@ describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
);
|
||||
await f.user.click(
|
||||
screen.getByRole("button", {
|
||||
name: index === 9 ? /결과 보기/ : /다음 문제/,
|
||||
name: index === questions.length - 1 ? /결과 보기/ : /다음 문제/,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -156,7 +157,8 @@ describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
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.user.click(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
);
|
||||
@@ -201,7 +203,7 @@ describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("v0.1.1")).toBeInTheDocument();
|
||||
expect(screen.getByText("v0.2.3")).toBeInTheDocument();
|
||||
expect(f.audio.play).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
@@ -234,7 +236,7 @@ describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
expect(f.audio.play).toHaveBeenCalledTimes(2);
|
||||
await f.finish();
|
||||
});
|
||||
it("혼합 모드 열 문제를 키보드·텍스트로 풀고 결과에서 다시 시작한다", async () => {
|
||||
it("혼합 모드 세 문제를 키보드·텍스트로 풀고 결과에서 다시 시작한다", async () => {
|
||||
const f = fixture();
|
||||
const questions = createQuestions(42, "mixed");
|
||||
await f.user.click(
|
||||
@@ -259,18 +261,18 @@ describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
expect(screen.getByText(q.sentence)).toBeInTheDocument();
|
||||
await f.user.click(
|
||||
screen.getByRole("button", {
|
||||
name: i === 9 ? /결과 보기/ : /다음 문제/,
|
||||
name: i === questions.length - 1 ? /결과 보기/ : /다음 문제/,
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /오늘의 소리 탐험/ }),
|
||||
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: "다시 시작" }));
|
||||
await f.user.click(screen.getByRole("button", { name: "처음 화면으로" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeEnabled();
|
||||
@@ -325,7 +327,8 @@ describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
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.user.click(screen.getByRole("button", { name: "처음 화면으로" }));
|
||||
await f.finish();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -334,3 +337,227 @@ describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 새 라운드의 시작 단계와 수동 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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user