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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
// @vitest-environment node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
import { ENVIRONMENTS } from "../src/content";
|
||||
|
||||
// 생성기 자기 보고 대신 배포 바이트의 형식·파형·권리 추적 정보를 독립 측정한다.
|
||||
it("여덟 현장 녹음은 고유 PCM16 WAV·짧은 fade·완전한 provenance/notice를 가진다", () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync("public/ambience/manifest.json", "utf8"),
|
||||
);
|
||||
expect(manifest.version).toBe("0.2.3");
|
||||
expect(
|
||||
manifest.files.map((f: { environmentId: string }) => f.environmentId),
|
||||
).toEqual(ENVIRONMENTS.map((e) => e.id));
|
||||
expect(readdirSync("public/ambience").sort()).toEqual(
|
||||
[
|
||||
...ENVIRONMENTS.map((e) => `${e.id}.wav`),
|
||||
"manifest.json",
|
||||
"AMBIENCE_NOTICES.md",
|
||||
].sort(),
|
||||
);
|
||||
const notice = readFileSync("public/ambience/AMBIENCE_NOTICES.md", "utf8");
|
||||
const hashes = new Set();
|
||||
for (const entry of manifest.files) {
|
||||
for (const key of [
|
||||
"displayName",
|
||||
"pageUrl",
|
||||
"directUrl",
|
||||
"creator",
|
||||
"license",
|
||||
"licenseUrl",
|
||||
"attribution",
|
||||
"conversion",
|
||||
"sourceSha256",
|
||||
"sha256",
|
||||
"bytes",
|
||||
"duration",
|
||||
"rms",
|
||||
"peak",
|
||||
])
|
||||
expect(entry[key], key).toBeTruthy();
|
||||
expect(entry.sourceSha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(entry.license).toMatch(/CC0|Public domain|CC BY(?:-SA)? 4\.0/);
|
||||
const section = notice
|
||||
.split("## ")
|
||||
.find((s: string) =>
|
||||
s.startsWith(`${entry.displayName} — ${entry.environmentId}\n`),
|
||||
)!;
|
||||
expect(section).toContain(entry.attribution);
|
||||
for (const line of [
|
||||
`Creator: ${entry.creator}`,
|
||||
`Source: ${entry.pageUrl}`,
|
||||
`Download: ${entry.directUrl}`,
|
||||
`License: ${entry.license} — ${entry.licenseUrl}`,
|
||||
`Output license: ${entry.outputLicense}`,
|
||||
`Changes: ${entry.conversion.operations}`,
|
||||
])
|
||||
expect(section).toContain(line);
|
||||
expect(notice).toContain(entry.licenseUrl);
|
||||
expect(notice).toContain(entry.pageUrl);
|
||||
const bytes = readFileSync(`public/ambience/${entry.file}`);
|
||||
const hash = createHash("sha256").update(bytes).digest("hex");
|
||||
hashes.add(hash);
|
||||
expect(hash).toBe(entry.sha256);
|
||||
expect(bytes.length).toBe(entry.bytes);
|
||||
expect(bytes.toString("ascii", 0, 4)).toBe("RIFF");
|
||||
expect(bytes.toString("ascii", 8, 12)).toBe("WAVE");
|
||||
expect(bytes.readUInt16LE(20)).toBe(1);
|
||||
expect(bytes.readUInt16LE(22)).toBe(1);
|
||||
expect(bytes.readUInt32LE(24)).toBe(24000);
|
||||
expect(bytes.readUInt16LE(34)).toBe(16);
|
||||
expect(bytes.readUInt32LE(40)).toBe(bytes.length - 44);
|
||||
const samples = Array.from(
|
||||
{ length: (bytes.length - 44) / 2 },
|
||||
(_, i) => bytes.readInt16LE(44 + i * 2) / 32768,
|
||||
);
|
||||
const rms = Math.sqrt(
|
||||
samples.reduce((sum, n) => sum + n * n, 0) / samples.length,
|
||||
);
|
||||
const peak = samples.reduce((max, n) => Math.max(max, Math.abs(n)), 0);
|
||||
expect(rms).toBeCloseTo(entry.rms, 10);
|
||||
expect(peak).toBeCloseTo(entry.peak, 10);
|
||||
expect(rms).toBeGreaterThan(0.003);
|
||||
expect(peak).toBeLessThanOrEqual(0.85);
|
||||
expect(samples[0]).toBe(0);
|
||||
expect(samples.at(-1)).toBe(0);
|
||||
expect(entry.duration).toBe(samples.length / 24000);
|
||||
expect(entry.duration).toBeGreaterThanOrEqual(8);
|
||||
expect(entry.duration).toBeLessThanOrEqual(20);
|
||||
expect(entry.conversion.fadeSeconds).toBeGreaterThanOrEqual(0.025);
|
||||
expect(entry.conversion.startSeconds).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
expect(hashes.size).toBe(8);
|
||||
});
|
||||
|
||||
it("원본 계획은 임시 경로 대신 검증된 해시·바이트·직접 미디어 길이를 고정한다", () => {
|
||||
const plan = JSON.parse(
|
||||
readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"),
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync("public/ambience/manifest.json", "utf8"),
|
||||
);
|
||||
const evidence = JSON.parse(
|
||||
readFileSync(".agent/evidence-v0.2.3/source-integrity.json", "utf8"),
|
||||
);
|
||||
for (const [index, asset] of plan.assets.entries()) {
|
||||
const original = evidence.find(
|
||||
(e: { file: string }) => e.file.split(".")[0] === asset.environmentId,
|
||||
);
|
||||
expect.soft(asset).not.toHaveProperty("verifiedLocalSource");
|
||||
expect.soft(asset).not.toHaveProperty("sourceDurationSeconds");
|
||||
expect.soft(asset.expectedSourceSha256).toBe(original.sha256);
|
||||
expect.soft(asset.sourceBytes).toBe(original.bytes);
|
||||
expect
|
||||
.soft(asset.directMediaDurationSeconds)
|
||||
.toBe(Number(original.probe.format.duration));
|
||||
expect.soft(asset.sourceVerification).toEqual({
|
||||
status: "verified",
|
||||
dateUtc: "2026-10-14",
|
||||
method:
|
||||
"SHA-256, byte count, ffprobe format.duration, full FFmpeg decode",
|
||||
evidence: ".agent/evidence-v0.2.3/source-integrity.json",
|
||||
});
|
||||
expect.soft(manifest.files[index]).toMatchObject(asset);
|
||||
expect
|
||||
.soft(manifest.files[index].sourceSha256)
|
||||
.toBe(asset.expectedSourceSha256);
|
||||
}
|
||||
});
|
||||
|
||||
it("선택 구간은 승인 계획의 불변 provenance이며 manifest와 고지에 동일하게 남는다", () => {
|
||||
const plan = JSON.parse(
|
||||
readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"),
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync("public/ambience/manifest.json", "utf8"),
|
||||
);
|
||||
const notice = readFileSync("public/ambience/AMBIENCE_NOTICES.md", "utf8");
|
||||
const starts = {
|
||||
street: 35,
|
||||
mall: 36,
|
||||
theater: 45,
|
||||
department: 90,
|
||||
market: 24,
|
||||
subway: 65,
|
||||
cafe: 0,
|
||||
rain: 180,
|
||||
};
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
plan.assets.map(
|
||||
(asset: {
|
||||
environmentId: string;
|
||||
selectedSegmentStartSeconds: number;
|
||||
}) => [asset.environmentId, asset.selectedSegmentStartSeconds],
|
||||
),
|
||||
),
|
||||
).toEqual(starts);
|
||||
for (const asset of plan.assets) {
|
||||
const start = asset.selectedSegmentStartSeconds;
|
||||
expect(Number.isFinite(start)).toBe(true);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(start + 12).toBeLessThanOrEqual(asset.directMediaDurationSeconds);
|
||||
const entry = manifest.files.find(
|
||||
(e: { environmentId: string }) => e.environmentId === asset.environmentId,
|
||||
);
|
||||
expect(entry.selectedSegmentStartSeconds).toBe(start);
|
||||
expect(entry.conversion.startSeconds).toBe(start);
|
||||
expect(entry.conversion.durationSeconds).toBe(12);
|
||||
const section = notice
|
||||
.split("## ")
|
||||
.find((s: string) =>
|
||||
s.startsWith(`${asset.displayName} — ${asset.environmentId}\n`),
|
||||
);
|
||||
expect(section).toContain(`source seconds ${start}–${start + 12}.`);
|
||||
}
|
||||
});
|
||||
|
||||
it("최종 ASR 증거는 정확한 WAV와 연결되며 청취·법적 인수로 주장하지 않는다", () => {
|
||||
const directory = ".agent/evidence-v0.2.3/privacy-screening";
|
||||
const screening = JSON.parse(
|
||||
readFileSync(`${directory}/final-asr.json`, "utf8"),
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync("public/ambience/manifest.json", "utf8"),
|
||||
);
|
||||
expect(screening.tool).toMatchObject({
|
||||
name: "faster-whisper",
|
||||
version: "1.2.1",
|
||||
});
|
||||
expect(screening.model).toMatchObject({ name: "tiny", multilingual: true });
|
||||
expect(screening.model.revision).toMatch(/^[a-f0-9]{40}$/);
|
||||
expect(screening.requiredEmptyPassed).toBe(true);
|
||||
expect(screening.requiredEmptyEnvironmentIds).toEqual([
|
||||
"mall",
|
||||
"market",
|
||||
"cafe",
|
||||
]);
|
||||
expect(screening.method).toMatchObject({
|
||||
device: "cpu",
|
||||
computeType: "int8",
|
||||
});
|
||||
expect(screening.method.parameters).toMatchObject({
|
||||
task: "transcribe",
|
||||
language: null,
|
||||
beam_size: 5,
|
||||
best_of: 5,
|
||||
vad_filter: true,
|
||||
vad_parameters: null,
|
||||
condition_on_previous_text: true,
|
||||
});
|
||||
expect(screening.method.manifestSha256).toBe(
|
||||
createHash("sha256")
|
||||
.update(readFileSync("public/ambience/manifest.json"))
|
||||
.digest("hex"),
|
||||
);
|
||||
expect(screening.limitations).toMatchObject({
|
||||
screeningOnly: true,
|
||||
humanListeningAcceptance: false,
|
||||
legalPrivacyClearance: false,
|
||||
textIsVerifiedTranscript: false,
|
||||
});
|
||||
expect(
|
||||
screening.files.map((e: { environmentId: string }) => e.environmentId),
|
||||
).toEqual(ENVIRONMENTS.map((e) => e.id));
|
||||
for (const entry of manifest.files) {
|
||||
const result = screening.files.find(
|
||||
(e: { environmentId: string }) => e.environmentId === entry.environmentId,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
file: `public/ambience/${entry.file}`,
|
||||
sha256: entry.sha256,
|
||||
bytes: entry.bytes,
|
||||
durationSeconds: entry.duration,
|
||||
selectedSegmentStartSeconds: entry.selectedSegmentStartSeconds,
|
||||
sourceSha256: entry.sourceSha256,
|
||||
});
|
||||
expect(result.language).toEqual(expect.any(String));
|
||||
expect(result.languageProbability).toEqual(expect.any(Number));
|
||||
expect(Array.isArray(result.segments)).toBe(true);
|
||||
if (["mall", "market", "cafe"].includes(entry.environmentId)) {
|
||||
const starts: Record<string, number> = { mall: 36, market: 24, cafe: 0 };
|
||||
expect(result.selectedSegmentStartSeconds).toBe(
|
||||
starts[entry.environmentId],
|
||||
);
|
||||
expect(result.segments).toEqual([]);
|
||||
expect(result.text).toBe("");
|
||||
expect(result.info.duration_after_vad).toBe(0);
|
||||
}
|
||||
}
|
||||
const provenance = JSON.parse(
|
||||
readFileSync(`${directory}/earlier-asr-provenance.json`, "utf8"),
|
||||
);
|
||||
expect(provenance.textIsVerifiedTranscript).toBe(false);
|
||||
for (const record of provenance.records)
|
||||
expect(
|
||||
createHash("sha256")
|
||||
.update(readFileSync(`${directory}/${record.savedFile}`))
|
||||
.digest("hex"),
|
||||
).toBe(record.sha256);
|
||||
});
|
||||
|
||||
it("101개 음성·다섯 현장음 및 이전 evidence 바이트를 보존한다", () => {
|
||||
const baseline = JSON.parse(
|
||||
readFileSync(
|
||||
".agent/evidence-v0.2.3/privacy-screening/baseline.json",
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
let voices = 0,
|
||||
unchangedAmbience = 0,
|
||||
changedAmbience = 0;
|
||||
for (const [path, before] of Object.entries(baseline.wavSha256)) {
|
||||
const actual = createHash("sha256")
|
||||
.update(readFileSync(path))
|
||||
.digest("hex");
|
||||
if (/public\/ambience\/(mall|market|cafe)\.wav$/.test(path)) {
|
||||
expect(actual).not.toBe(before);
|
||||
changedAmbience++;
|
||||
} else {
|
||||
expect(actual).toBe(before);
|
||||
if (path.startsWith("public/audio/")) voices++;
|
||||
else unchangedAmbience++;
|
||||
}
|
||||
}
|
||||
expect([voices, unchangedAmbience, changedAmbience]).toEqual([101, 5, 3]);
|
||||
for (const [path, before] of Object.entries(baseline.priorAgentFiles)) {
|
||||
if (baseline.mutableAgentFiles.includes(path)) continue;
|
||||
expect(
|
||||
createHash("sha256").update(readFileSync(path)).digest("hex"),
|
||||
path,
|
||||
).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it("재시도는 market/cafe만 바꾸고 mall 및 실패·no-VAD 증거를 바이트 그대로 보존한다", () => {
|
||||
const directory = ".agent/evidence-v0.2.3/privacy-screening";
|
||||
const baseline = JSON.parse(
|
||||
readFileSync(`${directory}/retry-market24-cafe0/baseline.json`, "utf8"),
|
||||
);
|
||||
const hash = (path: string) =>
|
||||
createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
const prior = `${directory}/prior-attempt-market60-cafe180`;
|
||||
expect(hash(`${prior}/final-asr.json`)).toBe(
|
||||
"01c8a92fab9f8df7a5f778c5f7e478ce96bcedc33e11a1055bd29154a08b706b",
|
||||
);
|
||||
const failed = JSON.parse(readFileSync(`${prior}/final-asr.json`, "utf8"));
|
||||
expect(failed.requiredEmptyPassed).toBe(false);
|
||||
expect(failed.limitations).toMatchObject({
|
||||
humanListeningAcceptance: false,
|
||||
legalPrivacyClearance: false,
|
||||
});
|
||||
for (const [environmentId, start, count] of [
|
||||
["mall", 36, 0],
|
||||
["market", 60, 3],
|
||||
["cafe", 180, 1],
|
||||
]) {
|
||||
const result = failed.files.find(
|
||||
(f: { environmentId: string }) => f.environmentId === environmentId,
|
||||
);
|
||||
expect(result.selectedSegmentStartSeconds).toBe(start);
|
||||
expect(result.segments).toHaveLength(count as number);
|
||||
}
|
||||
expect(hash(`${directory}/no-vad-asr.json`)).toBe(
|
||||
"14b42ee0b80c194caa3c0de2644f647d6d7720c60c2208374778619f47c2e969",
|
||||
);
|
||||
for (const [path, before] of Object.entries(
|
||||
baseline.immutablePriorAttemptFiles,
|
||||
))
|
||||
expect(hash(path), path).toBe(before);
|
||||
for (const [path, before] of Object.entries(baseline.priorAgentFiles)) {
|
||||
if (!baseline.mutableAgentFiles.includes(path))
|
||||
expect(hash(path), path).toBe(before);
|
||||
}
|
||||
const changed: string[] = [];
|
||||
let voices = 0,
|
||||
preservedAmbience = 0;
|
||||
for (const [path, before] of Object.entries(baseline.wavSha256)) {
|
||||
if (/public\/ambience\/(market|cafe)\.wav$/.test(path)) {
|
||||
expect(hash(path), path).not.toBe(before);
|
||||
changed.push(path);
|
||||
} else {
|
||||
expect(hash(path), path).toBe(before);
|
||||
if (path.startsWith("public/audio/")) voices++;
|
||||
else preservedAmbience++;
|
||||
}
|
||||
}
|
||||
expect(changed.sort()).toEqual([
|
||||
"public/ambience/cafe.wav",
|
||||
"public/ambience/market.wav",
|
||||
]);
|
||||
expect([voices, preservedAmbience]).toEqual([101, 6]);
|
||||
});
|
||||
@@ -35,8 +35,8 @@ const expected = [
|
||||
// 생성기의 검증 함수를 재사용하지 않고 실제 WAV 바이트를 직접 읽는다.
|
||||
// 문장·해시·헤더·신호를 묶어 manifest만 갱신하는 거짓 업그레이드를 막는다.
|
||||
describe("저장한 한국어 neural 음성 자산", () => {
|
||||
it("0.1.1 neural provider·voice·prosody와 실측 도구 버전을 기록한다", () => {
|
||||
expect.soft(manifest.version).toBe("0.1.1");
|
||||
it("0.2.3 neural provider·voice·prosody와 실측 도구 버전을 기록한다", () => {
|
||||
expect.soft(manifest.version).toBe("0.2.3");
|
||||
expect.soft(manifest.provider).toBe("Microsoft Edge online TTS");
|
||||
expect.soft(manifest.voice).toBe("ko-KR-HyunsuMultilingualNeural");
|
||||
expect.soft(manifest.rate).toBe("-6%");
|
||||
|
||||
@@ -3,10 +3,10 @@ import { isAudioRequest } from "./browser/audio-requests";
|
||||
|
||||
it("WAV 요청 판정은 query의 버전·순서와 무관하게 pathname을 사용한다", () => {
|
||||
for (const path of [
|
||||
"/audio/intro.wav?v=0.1.1",
|
||||
"/audio/luck-apple.wav?v=0.1.1",
|
||||
"/audio/luck-apple.wav?retry=1&v=0.1.1",
|
||||
"/audio/luck-apple.wav?v=0.1.1&retry=1",
|
||||
"/audio/intro.wav?v=0.2.3",
|
||||
"/audio/luck-apple.wav?v=0.2.3",
|
||||
"/audio/luck-apple.wav?retry=1&v=0.2.3",
|
||||
"/audio/luck-apple.wav?v=0.2.3&retry=1",
|
||||
"/audio/luck-apple.wav",
|
||||
]) {
|
||||
expect
|
||||
@@ -17,7 +17,7 @@ it("WAV 요청 판정은 query의 버전·순서와 무관하게 pathname을 사
|
||||
"/audio/manifest.json?file=intro.wav",
|
||||
"/other/intro.wav",
|
||||
"/audio-backup/intro.wav",
|
||||
"/audio/intro.wav.txt?v=0.1.1",
|
||||
"/audio/intro.wav.txt?v=0.2.3",
|
||||
]) {
|
||||
expect
|
||||
.soft(isAudioRequest(new URL(path, "http://localhost")), path)
|
||||
|
||||
+86
-13
@@ -31,7 +31,8 @@ function fixture() {
|
||||
sampleRate: rate,
|
||||
numberOfChannels: 1,
|
||||
getChannelData: () => data,
|
||||
copyToChannel: vi.fn(),
|
||||
// 실제 AudioBuffer처럼 복사를 반영해야 정규화 뒤 피크를 검증할 수 있다.
|
||||
copyToChannel: vi.fn((samples: Float32Array) => data.set(samples)),
|
||||
};
|
||||
}
|
||||
const sources: ReturnType<typeof makeSource>[] = [];
|
||||
@@ -95,8 +96,9 @@ describe("오디오 재생 수명과 믹서", () => {
|
||||
f.sources.at(-1)!.onended?.();
|
||||
await done;
|
||||
expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||
"/audio/intro.wav?v=0.1.1",
|
||||
`/audio/${question.id}.wav?v=0.1.1`,
|
||||
"/audio/intro.wav?v=0.2.3",
|
||||
`/audio/${question.id}.wav?v=0.2.3`,
|
||||
question.environment.audioUrl,
|
||||
]);
|
||||
f.engine.dispose();
|
||||
});
|
||||
@@ -128,10 +130,12 @@ describe("오디오 재생 수명과 믹서", () => {
|
||||
f.sources.at(-1)!.onended?.();
|
||||
await done;
|
||||
expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||
"/audio/intro.wav?v=0.1.1",
|
||||
`/audio/${question.id}.wav?v=0.1.1`,
|
||||
"/audio/intro.wav?v=0.1.1",
|
||||
`/audio/${question.id}.wav?v=0.1.1`,
|
||||
"/audio/intro.wav?v=0.2.3",
|
||||
`/audio/${question.id}.wav?v=0.2.3`,
|
||||
question.environment.audioUrl,
|
||||
"/audio/intro.wav?v=0.2.3",
|
||||
`/audio/${question.id}.wav?v=0.2.3`,
|
||||
question.environment.audioUrl,
|
||||
]);
|
||||
f.engine.dispose();
|
||||
},
|
||||
@@ -150,7 +154,11 @@ describe("오디오 재생 수명과 믹서", () => {
|
||||
f.fetcher.mock.calls.map(
|
||||
([url]) => new URL(String(url), "http://localhost").pathname,
|
||||
),
|
||||
).toEqual(["/audio/intro.wav", `/audio/${question.id}.wav`]);
|
||||
).toEqual([
|
||||
"/audio/intro.wav",
|
||||
`/audio/${question.id}.wav`,
|
||||
new URL(question.environment.audioUrl, "http://localhost").pathname,
|
||||
]);
|
||||
// master, 배경, 목표, 안내의 gain을 분리한다. 안내는 점차 소음에 묻힌다.
|
||||
expect(f.gains[1]!.gain.setValueAtTime).toHaveBeenCalledWith(
|
||||
1,
|
||||
@@ -160,10 +168,9 @@ describe("오디오 재생 수명과 믹서", () => {
|
||||
10 ** (question.db / 20),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(f.gains[3]!.gain.linearRampToValueAtTime).toHaveBeenCalledWith(
|
||||
0.12,
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(
|
||||
f.gains[3]!.gain.linearRampToValueAtTime.mock.calls[0]![0],
|
||||
).toBeCloseTo(0.12, 10);
|
||||
expect(f.sources[2]!.start.mock.calls[0]![0]!).toBeGreaterThan(
|
||||
f.sources[1]!.start.mock.calls[0]![0]!,
|
||||
);
|
||||
@@ -221,7 +228,7 @@ describe("오디오 재생 수명과 믹서", () => {
|
||||
);
|
||||
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(
|
||||
@@ -246,3 +253,69 @@ describe("오디오 재생 수명과 믹서", () => {
|
||||
await done;
|
||||
});
|
||||
});
|
||||
|
||||
// 배경 파일은 길이가 짧아도 루프 소스 하나로 유지하며 목표 ended에서 함께 정리한다.
|
||||
it("선택한 녹음을 디코드·루프하고 목표 종료에서 모든 소스와 fetch를 정리한다", async () => {
|
||||
const f = fixture();
|
||||
const done = f.engine.play(question, false, vi.fn());
|
||||
await flush();
|
||||
expect(f.fetcher).toHaveBeenCalledWith(
|
||||
question.environment.audioUrl,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(f.context.decodeAudioData).toHaveBeenCalledTimes(2);
|
||||
expect(f.sources[0]!.loop).toBe(true);
|
||||
expect(f.sources[1]!.loop).toBe(false);
|
||||
f.sources[1]!.onended?.();
|
||||
await done;
|
||||
expect(f.sources.every((s) => s.stop.mock.calls.length === 1)).toBe(true);
|
||||
for (const [, options] of f.fetcher.mock.calls)
|
||||
expect(options!.signal!.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("배경 404·디코드 실패는 한국어 복구 메시지이고 재시도로 성공한다", async () => {
|
||||
const f = fixture();
|
||||
f.fetcher.mockImplementationOnce(
|
||||
async () =>
|
||||
({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }) as Response,
|
||||
);
|
||||
f.fetcher.mockResolvedValueOnce({ ok: false } as Response);
|
||||
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
|
||||
/배경.*다시/,
|
||||
);
|
||||
f.context.decodeAudioData.mockRejectedValueOnce(new Error("EncodingError"));
|
||||
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("로드 시간초과는 fetch를 취소하고 늦은 디코드가 소스를 만들지 않는다", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const f = fixture();
|
||||
let release!: (buffer: ReturnType<typeof f.context.createBuffer>) => void;
|
||||
f.context.decodeAudioData.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const done = f.engine.play(question, false, vi.fn());
|
||||
const failed = expect(done).rejects.toThrow("준비 시간이 초과");
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
await failed;
|
||||
expect(f.fetcher.mock.calls[0]![1]!.signal!.aborted).toBe(true);
|
||||
release(f.context.createBuffer(1, 100, 24000));
|
||||
await flush();
|
||||
expect(f.sources).toHaveLength(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
f.engine.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
+75
-14
@@ -4,44 +4,89 @@ import { createQuestions } from "../../src/game";
|
||||
import { isAudioRequest } from "./audio-requests";
|
||||
|
||||
// 과거 evidence의 검증 스크립트는 보존하고, query URL 전수 검사를 정규 suite로 옮긴다.
|
||||
test("intro·목표 101개 versioned WAV를 실제 Chrome에서 해시·디코드 검증한다", async ({
|
||||
test("음성·현장음 109개 versioned WAV를 실제 Chrome에서 해시·디코드 검증한다", async ({
|
||||
page,
|
||||
browser,
|
||||
}, info) => {
|
||||
const external: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const failed: string[] = [];
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== "http://127.0.0.1:4173") external.push(url.href);
|
||||
if (isAudioRequest(url)) requests.push(url.href);
|
||||
if (isAudioRequest(url) || url.pathname.startsWith("/ambience/"))
|
||||
requests.push(url.href);
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on("requestfailed", (request) => failed.push(request.url()));
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.1.1");
|
||||
await expect(page.locator(".version")).toHaveText("v0.1.1");
|
||||
await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.2.3");
|
||||
await expect(page.locator(".version")).toHaveText("v0.2.3");
|
||||
const plan = JSON.parse(
|
||||
readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"),
|
||||
);
|
||||
const published = await page.evaluate(async () => ({
|
||||
manifest: await (await fetch("/ambience/manifest.json?v=0.2.3")).json(),
|
||||
notice: await (await fetch("/ambience/AMBIENCE_NOTICES.md?v=0.2.3")).text(),
|
||||
}));
|
||||
expect(published.manifest).toEqual(
|
||||
JSON.parse(readFileSync("public/ambience/manifest.json", "utf8")),
|
||||
);
|
||||
for (const asset of plan.assets) {
|
||||
const start = asset.selectedSegmentStartSeconds;
|
||||
expect(Number.isFinite(start)).toBe(true);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(start + 12).toBeLessThanOrEqual(asset.directMediaDurationSeconds);
|
||||
const entry = published.manifest.files.find(
|
||||
(e: { environmentId: string }) => e.environmentId === asset.environmentId,
|
||||
);
|
||||
expect(entry.selectedSegmentStartSeconds).toBe(start);
|
||||
expect(entry.conversion.startSeconds).toBe(start);
|
||||
expect(entry.conversion.durationSeconds).toBe(12);
|
||||
const section = published.notice
|
||||
.split("## ")
|
||||
.find((s: string) =>
|
||||
s.startsWith(`${asset.displayName} — ${asset.environmentId}\n`),
|
||||
);
|
||||
expect(section).toContain(`source seconds ${start}–${start + 12}.`);
|
||||
}
|
||||
// intro는 테스트에서 URL을 재조립하지 않고 배포 앱의 실제 요청을 관찰한다.
|
||||
const introRequest = page.waitForRequest(
|
||||
(request) => new URL(request.url()).pathname === "/audio/intro.wav",
|
||||
);
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
const introUrl = (await introRequest).url();
|
||||
await page.getByRole("button", { name: "처음으로" }).click();
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
|
||||
await page.getByRole("button", { name: "처음 화면으로" }).click();
|
||||
const targetUrls = [
|
||||
...new Set(
|
||||
Array.from({ length: 200 }, (_, seed) =>
|
||||
Array.from({ length: 1000 }, (_, seed) =>
|
||||
createQuestions(seed, "mixed").map((question) => question.audioUrl),
|
||||
).flat(),
|
||||
),
|
||||
];
|
||||
expect(targetUrls).toHaveLength(100);
|
||||
const ambience = JSON.parse(
|
||||
readFileSync("public/ambience/manifest.json", "utf8"),
|
||||
);
|
||||
const urls = [
|
||||
introUrl,
|
||||
...ambience.files.map(
|
||||
(entry: { file: string }) =>
|
||||
new URL(`/ambience/${entry.file}?v=0.2.3`, page.url()).href,
|
||||
),
|
||||
...targetUrls.map((url) => new URL(url, page.url()).href),
|
||||
];
|
||||
expect(
|
||||
new Set(urls.map((url) => new URL(url).searchParams.get("v"))),
|
||||
).toEqual(new Set(["0.1.1"]));
|
||||
).toEqual(new Set(["0.2.3"]));
|
||||
expect(
|
||||
urls.every((url) => new URL(url).origin === new URL(page.url()).origin),
|
||||
).toBe(true);
|
||||
@@ -51,9 +96,24 @@ test("intro·목표 101개 versioned WAV를 실제 Chrome에서 해시·디코
|
||||
version: string;
|
||||
files: { file: string; sha256: string; bytes: number; duration: number }[];
|
||||
};
|
||||
expect(manifest.version).toBe("0.1.1");
|
||||
expect(manifest.version).toBe("0.2.3");
|
||||
// 두 manifest의 파일명 충돌 없이 경로를 명시해 109개 응답을 독립 대조한다.
|
||||
const entries = [
|
||||
...manifest.files.map((entry) => ({
|
||||
...entry,
|
||||
path: `/audio/${entry.file}`,
|
||||
})),
|
||||
...ambience.files.map(
|
||||
(entry: {
|
||||
file: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
duration: number;
|
||||
}) => ({ ...entry, path: `/ambience/${entry.file}` }),
|
||||
),
|
||||
];
|
||||
expect(urls.map((url) => new URL(url).pathname).sort()).toEqual(
|
||||
manifest.files.map((entry) => `/audio/${entry.file}`).sort(),
|
||||
entries.map((entry) => entry.path).sort(),
|
||||
);
|
||||
requests.length = 0;
|
||||
const rows = await page.evaluate(
|
||||
@@ -63,7 +123,7 @@ test("intro·목표 101개 versioned WAV를 실제 Chrome에서 해시·디코
|
||||
for (const url of urls) {
|
||||
// query를 파일명으로 취급하지 않는다. 원문 파일과 응답 해시를 독립 대조한다.
|
||||
const entry = entries.find(
|
||||
(entry) => `/audio/${entry.file}` === new URL(url).pathname,
|
||||
(entry) => entry.path === new URL(url).pathname,
|
||||
)!;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw Error(`${url}: HTTP ${response.status}`);
|
||||
@@ -103,22 +163,23 @@ test("intro·목표 101개 versioned WAV를 실제 Chrome에서 해시·디코
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
{ urls, entries: manifest.files },
|
||||
{ urls, entries },
|
||||
);
|
||||
expect(rows).toHaveLength(101);
|
||||
expect(rows).toHaveLength(109);
|
||||
expect([...requests].sort()).toEqual([...urls].sort());
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
expect(failed).toEqual([]);
|
||||
const path = info.outputPath("browser-audio-integrity.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify(
|
||||
{ chrome: browser.version(), external, errors, files: rows },
|
||||
{ chrome: browser.version(), external, errors, failed, files: rows },
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
await info.attach("101 versioned WAV integrity", {
|
||||
await info.attach("109 versioned WAV integrity", {
|
||||
path,
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
+12
-11
@@ -41,8 +41,8 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
await expect.poll(() => audioUrls.length).toBe(2);
|
||||
expect(audioUrls.map((url) => url.searchParams.get("v"))).toEqual([
|
||||
"0.1.1",
|
||||
"0.1.1",
|
||||
"0.2.3",
|
||||
"0.2.3",
|
||||
]);
|
||||
await expect(page.getByRole("button", { name: /듣는 중/ })).toBeDisabled();
|
||||
await expect(page.locator(".sound-wave.active i").first()).toHaveCSS(
|
||||
@@ -50,14 +50,14 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
"none",
|
||||
);
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 3; 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(
|
||||
@@ -97,7 +97,7 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
page.getByRole("heading", { name: "정답이에요!" }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: i === 9 ? /결과 보기/ : /다음 문제/ })
|
||||
.getByRole("button", { name: i === 2 ? /결과 보기/ : /다음 문제/ })
|
||||
.click();
|
||||
}
|
||||
await expect(page.getByText("100%")).toBeVisible();
|
||||
@@ -112,16 +112,16 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
||||
await page.getByRole("button", { name: "다시 시작" }).click();
|
||||
await page.getByRole("button", { name: "처음 화면으로" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeVisible();
|
||||
expect(new Set(audioUrls.map((url) => url.searchParams.get("v")))).toEqual(
|
||||
new Set(["0.1.1"]),
|
||||
new Set(["0.2.3"]),
|
||||
);
|
||||
expect(
|
||||
audioUrls.filter((url) => url.pathname !== "/audio/intro.wav"),
|
||||
).toHaveLength(11);
|
||||
).toHaveLength(4);
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -150,7 +150,7 @@ for (const failed of ["intro", "target"]) {
|
||||
"소리를 재생하지 못했어요",
|
||||
);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(new URL(missing[0]!).searchParams.get("v")).toBe("0.1.1");
|
||||
expect(new URL(missing[0]!).searchParams.get("v")).toBe("0.2.3");
|
||||
expect(audioUrls).toHaveLength(2);
|
||||
await page.unroute(matchesFailure);
|
||||
await page.getByRole("button", { name: "다시 시도" }).click();
|
||||
@@ -162,7 +162,7 @@ for (const failed of ["intro", "target"]) {
|
||||
expect(audioUrls.slice(2).sort()).toEqual(audioUrls.slice(0, 2).sort());
|
||||
expect(
|
||||
new Set(audioUrls.map((url) => new URL(url).searchParams.get("v"))),
|
||||
).toEqual(new Set(["0.1.1"]));
|
||||
).toEqual(new Set(["0.2.3"]));
|
||||
const targetPath = audioUrls
|
||||
.map((url) => new URL(url).pathname)
|
||||
.find((path) => path !== "/audio/intro.wav")!;
|
||||
@@ -173,7 +173,8 @@ for (const failed of ["intro", "target"]) {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "정답이에요!" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "처음으로" }).click();
|
||||
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
|
||||
await page.getByRole("button", { name: "처음 화면으로" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeFocused();
|
||||
|
||||
@@ -19,3 +19,21 @@ test("릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공
|
||||
readFileSync("THIRD_PARTY_NOTICES.md", "utf8"),
|
||||
);
|
||||
});
|
||||
|
||||
// Accept: text/html을 보내도 누락 오디오는 SPA 문서로 대체하지 않아야 한다.
|
||||
test("누락 음성·환경음은 query와 HTML accept에도 404이고 출처 고지는 번들된다", async ({
|
||||
request,
|
||||
}) => {
|
||||
for (const directory of ["audio", "ambience"]) {
|
||||
const response = await request.get(`/${directory}/not-found.wav?v=0.2.3`, {
|
||||
headers: { Accept: "text/html" },
|
||||
});
|
||||
expect(response.status()).toBe(404);
|
||||
expect(await response.text()).not.toContain('<div id="root">');
|
||||
}
|
||||
const notice = await request.get("/ambience/AMBIENCE_NOTICES.md");
|
||||
expect(notice.ok()).toBe(true);
|
||||
expect(await notice.text()).toBe(
|
||||
readFileSync("public/ambience/AMBIENCE_NOTICES.md", "utf8"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { WORDS } from "../../src/content";
|
||||
|
||||
// 글자별 스타일 추측 대신 실제 Range 줄 상자를 비교해 어절 내부 분리를 검출한다.
|
||||
async function copyLayout(page: Page, selector: string) {
|
||||
return page.locator(selector).evaluateAll((elements) =>
|
||||
elements.flatMap((element) => {
|
||||
const problems: string[] = [];
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
for (const match of (node.textContent ?? "").matchAll(/[가-힣]+/g)) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, match.index!);
|
||||
range.setEnd(node, match.index! + match[0].length);
|
||||
if (new Set(Array.from(range.getClientRects(), (r) => r.y)).size > 1)
|
||||
problems.push(`split: ${match[0]}`);
|
||||
}
|
||||
}
|
||||
if (element.scrollWidth > element.clientWidth)
|
||||
problems.push(`overflow: ${element.textContent}`);
|
||||
return problems;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test("리뷰: 한국어 모드·결과 어절은 320px와 360px에서도 갈라지거나 넘치지 않는다", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
for (const width of [320, 360]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto("/");
|
||||
expect.soft(await copyLayout(page, ".mode-option small")).toEqual([]);
|
||||
await page.screenshot({
|
||||
path: info.outputPath(`copy-${width}-home.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
|
||||
expect
|
||||
.soft(
|
||||
await copyLayout(
|
||||
page,
|
||||
".results-panel h2, .results-panel p, .stats span, .results-panel th, .results-panel td",
|
||||
),
|
||||
)
|
||||
.toEqual([]);
|
||||
expect
|
||||
.soft(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.screenshot({
|
||||
path: info.outputPath(`copy-${width}-results.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("리뷰: 실제 진행 막대는 현재 문항의 1/3·2/3·3/3을 채운다", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
let target = "";
|
||||
page.on("request", (r) => {
|
||||
const path = new URL(r.url()).pathname;
|
||||
if (path.startsWith("/audio/") && path !== "/audio/intro.wav")
|
||||
target = path;
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByRole("radio", { name: "보기에서 선택" }).check();
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const bar = page.getByRole("progressbar");
|
||||
expect
|
||||
.soft(await bar.evaluate((el) => (el as HTMLProgressElement).position))
|
||||
.toBeCloseTo((index + 1) / 3);
|
||||
await expect
|
||||
.soft(bar)
|
||||
.toHaveAccessibleName("현재 문항 진행률", { timeout: 1000 });
|
||||
const png = await bar.screenshot({
|
||||
path: info.outputPath(`progress-${index + 1}.png`),
|
||||
});
|
||||
// 실제 그려진 중앙 행의 녹색 채움 비율까지 측정한다. DOM value만 맞는 빈 막대는 실패한다.
|
||||
const fill = await page.evaluate(async (base64) => {
|
||||
const bitmap = await createImageBitmap(
|
||||
new Blob([Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))], {
|
||||
type: "image/png",
|
||||
}),
|
||||
);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const row = context.getImageData(
|
||||
0,
|
||||
Math.floor(canvas.height / 2),
|
||||
canvas.width,
|
||||
1,
|
||||
).data;
|
||||
let green = 0;
|
||||
for (let x = 0; x < canvas.width; x++) {
|
||||
if (
|
||||
Math.abs(row[x * 4]! - 101) < 12 &&
|
||||
Math.abs(row[x * 4 + 1]! - 132) < 12 &&
|
||||
Math.abs(row[x * 4 + 2]! - 80) < 12
|
||||
)
|
||||
green++;
|
||||
}
|
||||
bitmap.close();
|
||||
return { pixels: green, width: canvas.width };
|
||||
}, png.toString("base64"));
|
||||
expect
|
||||
.soft(Math.abs(fill.pixels - (fill.width * (index + 1)) / 3))
|
||||
.toBeLessThanOrEqual(2);
|
||||
if (index) await page.getByRole("button", { name: /문장 듣기/ }).click();
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
const answer = WORDS.find((w) => target.endsWith(`-${w.id}.wav`))!.answer;
|
||||
await page.getByRole("button", { name: new RegExp(answer) }).click();
|
||||
await page
|
||||
.getByRole("button", { name: index === 2 ? /결과 보기/ : /다음 문제/ })
|
||||
.click();
|
||||
}
|
||||
await page.screenshot({
|
||||
path: info.outputPath("normal-results.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
expect(
|
||||
await copyLayout(page, ".results-panel h2, .results-panel p, .stats span"),
|
||||
).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { ENVIRONMENTS, WORDS } from "../../src/content";
|
||||
import { isAudioRequest } from "./audio-requests";
|
||||
|
||||
// 실제 소스 시작/정지를 관찰하되 브라우저의 원래 Web Audio 메서드는 그대로 실행한다.
|
||||
// 테스트가 ended를 조작하거나 가짜 음원을 주입하면 실제 재생 검증이 되지 않는다.
|
||||
declare global {
|
||||
interface Window {
|
||||
audioProbe: {
|
||||
starts: { loop: boolean; duration: number }[];
|
||||
stops: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.audioProbe = { starts: [], stops: 0 };
|
||||
const start = AudioBufferSourceNode.prototype.start;
|
||||
const stop = AudioBufferSourceNode.prototype.stop;
|
||||
AudioBufferSourceNode.prototype.start = function (...args) {
|
||||
window.audioProbe.starts.push({
|
||||
loop: this.loop,
|
||||
duration: this.buffer?.duration ?? 0,
|
||||
});
|
||||
return start.apply(this, args);
|
||||
};
|
||||
AudioBufferSourceNode.prototype.stop = function (...args) {
|
||||
window.audioProbe.stops++;
|
||||
return stop.apply(this, args);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
test("시작 단계·SNR·3문항 완료→다음 단계·재생 중 종료·응답 후 종료·홈 초기화", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
const errors: string[] = [],
|
||||
failed: string[] = [],
|
||||
external: string[] = [];
|
||||
let target = "";
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") errors.push(msg.text());
|
||||
});
|
||||
page.on("requestfailed", (r) => failed.push(r.url()));
|
||||
page.on("request", (r) => {
|
||||
const url = new URL(r.url());
|
||||
if (url.origin !== "http://127.0.0.1:4173") external.push(url.href);
|
||||
if (isAudioRequest(url) && url.pathname !== "/audio/intro.wav")
|
||||
target = url.pathname;
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByRole("radio", { name: "보기에서 선택" }).check();
|
||||
await page.getByLabel("시작 난이도").selectOption("4");
|
||||
const snr = page.getByRole("slider", { name: "목표 음성 SNR" });
|
||||
await expect(snr).toHaveValue("-3");
|
||||
await snr.fill("-4");
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
for (let index = 0; index < 3; index++) {
|
||||
if (index) await page.getByRole("button", { name: /문장 듣기/ }).click();
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByText("4단계 · SNR -4 dB")).toBeVisible();
|
||||
const answer = WORDS.find((word) =>
|
||||
target.endsWith(`-${word.id}.wav`),
|
||||
)!.answer;
|
||||
await page.getByRole("button", { name: new RegExp(answer) }).click();
|
||||
await page
|
||||
.getByRole("button", { name: index === 2 ? /결과 보기/ : /다음 문제/ })
|
||||
.click();
|
||||
}
|
||||
await expect(page.getByText("응답 3문항 · 정답 3문항")).toBeVisible();
|
||||
await page.getByRole("button", { name: "다음 난이도 · 5단계" }).click();
|
||||
await expect(page.getByText("5단계 · SNR -4 dB")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.audioProbe.starts.length))
|
||||
.toBe(10);
|
||||
// 예약 시작보다 조금 뒤 종료하여 fetch 취소뿐 아니라 실제 loop 정지를 검증한다.
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
|
||||
await expect(page.getByText("응답 0문항 · 정답 0문항")).toBeVisible();
|
||||
expect(await page.evaluate(() => window.audioProbe.stops)).toBe(10);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: info.outputPath("early-exit.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
||||
await page.getByRole("button", { name: "처음 화면으로" }).click();
|
||||
await expect(snr).toHaveValue("-3");
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await snr.fill("0");
|
||||
await page.getByRole("button", { name: /다시 듣기/ }).click();
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible();
|
||||
await expect(page.getByText("4단계 · SNR 0 dB")).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: new RegExp(
|
||||
WORDS.find((w) => target.endsWith(`-${w.id}.wav`))!.answer,
|
||||
),
|
||||
})
|
||||
.click();
|
||||
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
|
||||
await expect(page.getByText("응답 1문항 · 정답 1문항")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: "SNR 0 dB" }).first(),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "처음 화면으로" }).click();
|
||||
await expect(snr).toHaveValue("-3");
|
||||
expect({ errors, failed, external }).toEqual({
|
||||
errors: [],
|
||||
failed: [],
|
||||
external: [],
|
||||
});
|
||||
await info.attach("runtime-audit", {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
errors,
|
||||
failed,
|
||||
external,
|
||||
audio: await page.evaluate(() => window.audioProbe),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
test("여덟 환경의 실제 WAV를 각각 디코드하고 루프 재생한 뒤 즉시 정지한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests: string[] = [],
|
||||
errors: string[] = [],
|
||||
failed: string[] = [],
|
||||
external: string[] = [];
|
||||
page.on("request", (r) => {
|
||||
const url = new URL(r.url());
|
||||
if (url.pathname.startsWith("/ambience/")) requests.push(url.pathname);
|
||||
if (url.origin !== "http://127.0.0.1:4173") external.push(url.href);
|
||||
});
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") errors.push(m.text());
|
||||
});
|
||||
page.on("requestfailed", (r) => failed.push(r.url()));
|
||||
await page.goto("/");
|
||||
for (const [index, environment] of ENVIRONMENTS.entries()) {
|
||||
await page.getByLabel("배경 환경").selectOption(environment.id);
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.audioProbe.starts.length))
|
||||
.toBe((index + 1) * 3);
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
|
||||
await expect(page.getByText("응답 0문항 · 정답 0문항")).toBeVisible();
|
||||
await page.getByRole("button", { name: "처음 화면으로" }).click();
|
||||
}
|
||||
const probe = await page.evaluate(() => window.audioProbe);
|
||||
expect(probe.starts.filter((s) => s.loop).map((s) => s.duration)).toEqual(
|
||||
Array(8).fill(12),
|
||||
);
|
||||
expect(probe.stops).toBe(24);
|
||||
expect(requests).toEqual(ENVIRONMENTS.map((e) => `/ambience/${e.id}.wav`));
|
||||
expect({ errors, failed, external }).toEqual({
|
||||
errors: [],
|
||||
failed: [],
|
||||
external: [],
|
||||
});
|
||||
});
|
||||
|
||||
// 장애 주입은 정상 흐름의 네트워크 무결성 집계와 분리하고 재시도 성공을 확인한다.
|
||||
test("환경음 404는 한국어 오류와 재시도 후 정상 답변으로 복구한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route(/\/ambience\/.*\.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(/\/ambience\/.*\.wav/);
|
||||
await page.getByRole("button", { name: "다시 시도" }).click();
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
});
|
||||
@@ -54,12 +54,12 @@ test("난이도 설명은 모바일에서도 보인다", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
// DOM에만 설명을 남기고 모바일 CSS로 숨기면 컴포넌트 검증은 통과하므로
|
||||
// 실제 화면에서 난이도 배지와 쉬운 설명을 모두 읽을 수 있는지 확인한다.
|
||||
await expect.soft(page.locator(".db-note")).toBeVisible({ timeout: 1000 });
|
||||
await expect.soft(page.locator("#snr-help")).toBeVisible({ timeout: 1000 });
|
||||
await expect
|
||||
.soft(page.getByText("1단계 · SNR +6 dB"))
|
||||
.toBeVisible({ timeout: 1000 });
|
||||
await expect
|
||||
.soft(page.locator(".db-note"))
|
||||
.soft(page.locator("#snr-help"))
|
||||
.toContainText("음성이 배경보다 6 dB 커요.", { timeout: 1000 });
|
||||
});
|
||||
|
||||
@@ -116,3 +116,43 @@ test("한국어 제목은 단어 중간에서 줄바꿈하지 않는다", async
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// 새 설정도 기존 카드와 같은 터치 크기를 갖고 360px에서 레이블과 함께 넘치지 않아야 한다.
|
||||
test("시작 단계와 SNR 설정은 카드 안에 들어가고 터치 영역을 확보한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const panel = (await page
|
||||
.getByRole("region", { name: "나의 소리 탐험 준비" })
|
||||
.boundingBox())!;
|
||||
for (const control of [
|
||||
page.getByLabel("시작 난이도"),
|
||||
page.getByRole("slider", { name: "목표 음성 SNR" }),
|
||||
]) {
|
||||
const bounds = (await control.boundingBox())!;
|
||||
expect(bounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(panel.x);
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(panel.x + panel.width);
|
||||
}
|
||||
// select의 상자만 들어가도 선택한 글자는 화살표 뒤에서 잘릴 수 있다.
|
||||
const labelFits = await page.getByLabel("시작 난이도").evaluate((element) => {
|
||||
const select = element as HTMLSelectElement;
|
||||
const style = getComputedStyle(select);
|
||||
const context = document.createElement("canvas").getContext("2d")!;
|
||||
context.font = style.font;
|
||||
const available =
|
||||
select.clientWidth -
|
||||
parseFloat(style.paddingLeft) -
|
||||
parseFloat(style.paddingRight) -
|
||||
24;
|
||||
return (
|
||||
context.measureText(select.selectedOptions[0]!.text).width <= available
|
||||
);
|
||||
});
|
||||
expect(labelFits).toBe(true);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
+263
-2
@@ -1,7 +1,16 @@
|
||||
// @vitest-environment node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// 실제 배포는 하지 않는다. worktree 내부 임시 폴더에 public 자산만 복사한 뒤
|
||||
@@ -60,3 +69,255 @@ describe("production 자산 검증기", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// 새 배경 경로는 음성과 독립적이므로 실제 dist 사본의 누락/변조/과잉 번들을 검증한다.
|
||||
it.each(["wav", "manifest", "notice", "extra"])(
|
||||
"현장음 %s 손상은 빌드 실패로 처리한다",
|
||||
(damage) => {
|
||||
const temporary = mkdtempSync(".cache/ambience-build-");
|
||||
const verify = () =>
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import {verifyBuild} from './scripts/check-build.mjs'; verifyBuild(${JSON.stringify(temporary)});`,
|
||||
],
|
||||
{ encoding: "utf8", stdio: "pipe" },
|
||||
);
|
||||
try {
|
||||
cpSync("public", temporary, { recursive: true });
|
||||
expect(verify).not.toThrow();
|
||||
if (damage === "wav")
|
||||
writeFileSync(join(temporary, "ambience/street.wav"), "bad");
|
||||
if (damage === "manifest")
|
||||
writeFileSync(join(temporary, "ambience/manifest.json"), "{}");
|
||||
if (damage === "notice")
|
||||
rmSync(join(temporary, "ambience/AMBIENCE_NOTICES.md"));
|
||||
if (damage === "extra")
|
||||
writeFileSync(
|
||||
join(temporary, "ambience/original.ogg"),
|
||||
"should not ship",
|
||||
);
|
||||
expect(verify).toThrow(/현장음/);
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// public/manifest와 dist를 함께 훼손해도 독립적인 콘텐츠 목록과 개수가 막아야 한다.
|
||||
it.each(["empty", "truncated", "duplicate", "renamed", "extra", "missing"])(
|
||||
"음성 목록과 폴더를 함께 바꾼 %s 배포를 거부한다",
|
||||
(damage) => {
|
||||
mkdirSync(".cache", { recursive: true });
|
||||
const temporary = mkdtempSync(".cache/voice-inventory-");
|
||||
try {
|
||||
cpSync("public", join(temporary, "public"), { recursive: true });
|
||||
cpSync("public", join(temporary, "dist"), { recursive: true });
|
||||
for (const file of ["THIRD_PARTY_NOTICES.md", "package-lock.json"])
|
||||
cpSync(file, join(temporary, file));
|
||||
mkdirSync(join(temporary, ".agent"));
|
||||
cpSync(
|
||||
".agent/AMBIENCE-SOURCES-v0.2.3.json",
|
||||
join(temporary, ".agent/AMBIENCE-SOURCES-v0.2.3.json"),
|
||||
);
|
||||
symlinkSync(resolve("node_modules"), join(temporary, "node_modules"));
|
||||
const manifest = JSON.parse(
|
||||
readFileSync("public/audio/manifest.json", "utf8"),
|
||||
);
|
||||
const first = manifest.files[0];
|
||||
for (const directory of ["public", "dist"]) {
|
||||
const path = join(temporary, directory, "audio");
|
||||
if (damage === "empty" || damage === "truncated") {
|
||||
const removed = damage === "empty" ? manifest.files : [first];
|
||||
for (const entry of removed) rmSync(join(path, entry.file));
|
||||
}
|
||||
if (damage === "renamed") {
|
||||
cpSync(join(path, first.file), join(path, "unexpected.wav"));
|
||||
rmSync(join(path, first.file));
|
||||
}
|
||||
}
|
||||
if (damage === "empty") manifest.files = [];
|
||||
if (damage === "truncated") manifest.files.shift();
|
||||
if (damage === "duplicate") manifest.files[0] = manifest.files[1];
|
||||
if (damage === "renamed")
|
||||
manifest.files[0] = { ...first, file: "unexpected.wav" };
|
||||
if (damage === "extra")
|
||||
cpSync(
|
||||
join(temporary, "dist/audio", first.file),
|
||||
join(temporary, "dist/audio/extra.wav"),
|
||||
);
|
||||
if (damage === "missing")
|
||||
rmSync(join(temporary, "dist/audio", first.file));
|
||||
for (const directory of ["public", "dist"])
|
||||
writeFileSync(
|
||||
join(temporary, directory, "audio/manifest.json"),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
expect(() =>
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import {verifyBuild} from ${JSON.stringify(resolve("scripts/check-build.mjs"))}; verifyBuild("dist");`,
|
||||
],
|
||||
{ cwd: temporary, stdio: "pipe" },
|
||||
),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 사본 비교만으로는 양쪽 고지가 함께 축약되는 것을 감지할 수 없다.
|
||||
it.each([
|
||||
"street",
|
||||
"mall",
|
||||
"theater",
|
||||
"department",
|
||||
"market",
|
||||
"subway",
|
||||
"cafe",
|
||||
"rain",
|
||||
])(
|
||||
"%s 고지는 각 항목에 저작자·원문·다운로드·라이선스·파생권리·변경을 모두 포함한다",
|
||||
(id) => {
|
||||
mkdirSync(".cache", { recursive: true });
|
||||
const temporary = mkdtempSync(".cache/notice-fields-");
|
||||
try {
|
||||
for (const dir of ["public", "dist"])
|
||||
cpSync("public", join(temporary, dir), { recursive: true });
|
||||
for (const file of ["THIRD_PARTY_NOTICES.md", "package-lock.json"])
|
||||
cpSync(file, join(temporary, file));
|
||||
mkdirSync(join(temporary, ".agent"));
|
||||
cpSync(
|
||||
".agent/AMBIENCE-SOURCES-v0.2.3.json",
|
||||
join(temporary, ".agent/AMBIENCE-SOURCES-v0.2.3.json"),
|
||||
);
|
||||
symlinkSync(resolve("node_modules"), join(temporary, "node_modules"));
|
||||
const noticePath = existsSync("public/ambience/AMBIENCE_NOTICES.md")
|
||||
? "ambience/AMBIENCE_NOTICES.md"
|
||||
: "AMBIENCE_NOTICES.md";
|
||||
const manifest = JSON.parse(
|
||||
readFileSync("public/ambience/manifest.json", "utf8"),
|
||||
);
|
||||
// 완전한 기준 고지에서 한 항목의 필드만 지운다. 다른 항목에 같은 URL이 남아도 거부해야 한다.
|
||||
let complete = readFileSync(join("public", noticePath), "utf8");
|
||||
for (const entry of manifest.files) {
|
||||
if (!complete.includes(`Creator: ${entry.creator}\n`))
|
||||
complete = complete.replace(
|
||||
`Source: ${entry.pageUrl}`,
|
||||
`Creator: ${entry.creator}\nOutput license: ${entry.outputLicense}\nSource: ${entry.pageUrl}`,
|
||||
);
|
||||
}
|
||||
const writeNotice = (value: string) => {
|
||||
for (const dir of ["public", "dist"])
|
||||
writeFileSync(join(temporary, dir, noticePath), value);
|
||||
};
|
||||
const verify = () =>
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import {verifyBuild} from ${JSON.stringify(resolve("scripts/check-build.mjs"))}; verifyBuild("dist");`,
|
||||
],
|
||||
{ cwd: temporary, stdio: "pipe" },
|
||||
);
|
||||
writeNotice(complete);
|
||||
expect(verify).not.toThrow();
|
||||
const entry = manifest.files.find(
|
||||
(e: { environmentId: string }) => e.environmentId === id,
|
||||
);
|
||||
const sections = complete.split("## ");
|
||||
const index = sections.findIndex((s) =>
|
||||
s.startsWith(`${entry.displayName} — ${id}\n`),
|
||||
);
|
||||
for (const field of [
|
||||
"Creator:",
|
||||
"Source:",
|
||||
"Download:",
|
||||
"License:",
|
||||
"Output license:",
|
||||
"Changes:",
|
||||
]) {
|
||||
const modified = [...sections];
|
||||
modified[index] = modified[index]!.split("\n")
|
||||
.filter((line) => !line.startsWith(field))
|
||||
.join("\n");
|
||||
writeNotice(modified.join("## "));
|
||||
expect.soft(verify, `${id}: ${field}`).toThrow();
|
||||
}
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"sourceSha256",
|
||||
"expectedSourceSha256",
|
||||
"sourceBytes",
|
||||
"directMediaDurationSeconds",
|
||||
"selectedSegmentStartSeconds",
|
||||
"startSeconds",
|
||||
"durationSeconds",
|
||||
"outputLicense",
|
||||
"operations",
|
||||
])(
|
||||
"현장음 manifest 사본을 함께 바꿔도 승인 %s 계약 위반을 거부한다",
|
||||
(field) => {
|
||||
const temporary = mkdtempSync(".cache/ambience-plan-");
|
||||
try {
|
||||
for (const dir of ["public", "dist"])
|
||||
cpSync("public", join(temporary, dir), { recursive: true });
|
||||
for (const file of ["THIRD_PARTY_NOTICES.md", "package-lock.json"])
|
||||
cpSync(file, join(temporary, file));
|
||||
mkdirSync(join(temporary, ".agent"));
|
||||
cpSync(
|
||||
".agent/AMBIENCE-SOURCES-v0.2.3.json",
|
||||
join(temporary, ".agent/AMBIENCE-SOURCES-v0.2.3.json"),
|
||||
);
|
||||
symlinkSync(resolve("node_modules"), join(temporary, "node_modules"));
|
||||
const verify = () =>
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import {verifyBuild} from ${JSON.stringify(resolve("scripts/check-build.mjs"))}; verifyBuild("dist");`,
|
||||
],
|
||||
{ cwd: temporary, stdio: "pipe" },
|
||||
);
|
||||
expect(verify).not.toThrow();
|
||||
const manifest = JSON.parse(
|
||||
readFileSync("public/ambience/manifest.json", "utf8"),
|
||||
);
|
||||
if (field === "operations") manifest.files[0].conversion.operations = "";
|
||||
else if (field === "startSeconds" || field === "durationSeconds") {
|
||||
const entry = manifest.files[0];
|
||||
const previous = `source seconds ${entry.conversion.startSeconds}–${entry.conversion.startSeconds + entry.conversion.durationSeconds}.`;
|
||||
entry.conversion[field] += 1;
|
||||
const updated = `source seconds ${entry.conversion.startSeconds}–${entry.conversion.startSeconds + entry.conversion.durationSeconds}.`;
|
||||
for (const dir of ["public", "dist"]) {
|
||||
const path = join(temporary, dir, "ambience/AMBIENCE_NOTICES.md");
|
||||
writeFileSync(
|
||||
path,
|
||||
readFileSync(path, "utf8").replace(previous, updated),
|
||||
);
|
||||
}
|
||||
} else manifest.files[0][field] = "invalid";
|
||||
for (const dir of ["public", "dist"])
|
||||
writeFileSync(
|
||||
join(temporary, dir, "ambience/manifest.json"),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
expect(verify).toThrow();
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -178,7 +178,7 @@ describe("저장된 문제·환경 데이터", () => {
|
||||
"카페",
|
||||
"비 오는 거리",
|
||||
]);
|
||||
expect(new Set(ENVIRONMENTS.map((e) => e.seed)).size).toBe(8);
|
||||
expect(new Set(ENVIRONMENTS.map((e) => e.audioUrl)).size).toBe(8);
|
||||
expect(LEVELS).toHaveLength(5);
|
||||
LEVELS.forEach((l, i) => {
|
||||
if (i) expect(l.db).toBeLessThan(LEVELS[i - 1]!.db);
|
||||
|
||||
+26
-14
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ENVIRONMENTS, LEVELS } from "../src/content";
|
||||
import { dbToGain, normalizeRms, synthesizeBackground } from "../src/audio/dsp";
|
||||
import { dbToGain, normalizeRms, mixingGains } from "../src/audio/dsp";
|
||||
|
||||
// 청감이나 실제 음압은 장치마다 다르므로 테스트 대상은 디지털 RMS와 gain
|
||||
// 비율이다. 배경·음성을 같은 기준으로 정규화해야 표기한 상대 dB가 성립한다.
|
||||
@@ -19,19 +19,31 @@ describe("상대 dB와 결정론적 배경 합성", () => {
|
||||
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(","));
|
||||
it("여덟 환경은 고유 same-origin WAV이며 절차적 합성 매개변수를 갖지 않는다", () => {
|
||||
expect(new Set(ENVIRONMENTS.map((e) => e.audioUrl)).size).toBe(8);
|
||||
for (const environment of ENVIRONMENTS) {
|
||||
const url = new URL(environment.audioUrl, "http://localhost");
|
||||
expect(url.origin).toBe("http://localhost");
|
||||
expect(url.pathname).toBe(`/ambience/${environment.id}.wav`);
|
||||
expect(environment).not.toHaveProperty("seed");
|
||||
}
|
||||
});
|
||||
it("피크가 큰 녹음도 공통 감쇠로 합성 peak 0.8을 넘지 않고 SNR 비율을 보존한다", () => {
|
||||
const voice = normalizeRms(
|
||||
new Float32Array([1, ...Array(999).fill(0)]),
|
||||
0.03,
|
||||
);
|
||||
const background = normalizeRms(new Float32Array([0.2, -0.2]), 0.03);
|
||||
for (const db of [-12, -6, 0, 6, 12]) {
|
||||
const gains = mixingGains(voice, background, voice, db);
|
||||
expect(20 * Math.log10(gains.voice / gains.background)).toBeCloseTo(
|
||||
db,
|
||||
10,
|
||||
);
|
||||
expect(
|
||||
Math.max(...voice) * (gains.voice + gains.intro) +
|
||||
0.03 * gains.background,
|
||||
).toBeLessThanOrEqual(0.800001);
|
||||
}
|
||||
expect(signatures.size).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
+122
-19
@@ -1,3 +1,4 @@
|
||||
import { LEVELS } from "../src/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createQuestions,
|
||||
@@ -8,12 +9,13 @@ import {
|
||||
summarize,
|
||||
} from "../src/game";
|
||||
|
||||
// 세 문항 라운드에서는 1000개 seed를 표본으로 전체 100조합 도달성을 확인한다.
|
||||
// 고정 seed로 재현 가능성을 확보하되 seed를 바꾸면 10문제를 넘는 조합이
|
||||
// 실제로 선택되는지 본다. 오디오 URL도 같은 문장 조합을 가리켜야 한다.
|
||||
describe("출제와 정답 판정", () => {
|
||||
it("목표 WAV 100개 모두 같은 명시적 cache-busting 버전과 정답 mapping을 유지한다", () => {
|
||||
const questions = new Map(
|
||||
Array.from({ length: 200 }, (_, seed) => createQuestions(seed, "mixed"))
|
||||
Array.from({ length: 1000 }, (_, seed) => createQuestions(seed, "mixed"))
|
||||
.flat()
|
||||
.map((q) => [q.id, q]),
|
||||
);
|
||||
@@ -27,7 +29,7 @@ describe("출제와 정답 판정", () => {
|
||||
expect(q.options).toContain(q.word.answer);
|
||||
versions.add(url.searchParams.get("v"));
|
||||
}
|
||||
expect(versions).toEqual(new Set(["0.1.1"]));
|
||||
expect(versions).toEqual(new Set(["0.2.3"]));
|
||||
});
|
||||
it("실제 보기에 있는 경쟁 단어를 문장에 넣고 청취 연산별 순서를 유지한다", () => {
|
||||
for (let seed = 0; seed < 20; seed++) {
|
||||
@@ -45,14 +47,12 @@ describe("출제와 정답 판정", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
it("열 문제에 모든 유형·다섯 난이도·두 응답 방식을 섞는다", () => {
|
||||
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(questions).toHaveLength(3);
|
||||
expect(new Set(questions.map((q) => q.pattern.id)).size).toBe(3);
|
||||
expect(new Set(questions.map((q) => q.environment.id)).size).toBe(3);
|
||||
expect(questions.map((q) => q.level)).toEqual([1, 1, 1]);
|
||||
expect(new Set(questions.map((q) => q.mode))).toEqual(
|
||||
new Set(["choice", "text"]),
|
||||
);
|
||||
@@ -69,7 +69,7 @@ describe("출제와 정답 판정", () => {
|
||||
expect(
|
||||
new Set([...questions, ...createQuestions(91, "mixed")].map((q) => q.id))
|
||||
.size,
|
||||
).toBeGreaterThan(10);
|
||||
).toBeGreaterThan(3);
|
||||
expect(
|
||||
new Set(questions.map((q) => q.options.indexOf(q.word.answer))).size,
|
||||
).toBeGreaterThan(1);
|
||||
@@ -136,10 +136,10 @@ describe("게임 상태·점수·경합 방지", () => {
|
||||
expect(state.phase).toBe("loading");
|
||||
expect(state.answers).toHaveLength(0);
|
||||
});
|
||||
it("열 문제를 끝내면 정답률·연속 정답·단계별 결과를 집계한다", () => {
|
||||
it("세 문제를 끝내면 정답률·연속 정답·단계별 결과를 집계한다", () => {
|
||||
let state = initialGame(createQuestions(5, "choice"));
|
||||
expect(summarize(state).accuracy).toBe(0);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
state = gameReducer(state, { type: "PLAY" });
|
||||
state = gameReducer(state, { type: "STARTED" });
|
||||
state = gameReducer(state, { type: "ENDED" });
|
||||
@@ -150,19 +150,122 @@ describe("게임 상태·점수·경합 방지", () => {
|
||||
state = gameReducer(state, { type: "NEXT" });
|
||||
}
|
||||
expect(state.phase).toBe("results");
|
||||
expect(state.bestStreak).toBe(7);
|
||||
expect(state.streak).toBe(7);
|
||||
expect(state.bestStreak).toBe(2);
|
||||
expect(state.streak).toBe(0);
|
||||
const result = summarize(state);
|
||||
expect(result.accuracy).toBe(90);
|
||||
expect(result.correct).toBe(9);
|
||||
expect(result.score).toBe(2990);
|
||||
expect(result.accuracy).toBe(67);
|
||||
expect(result.correct).toBe(2);
|
||||
expect(result.score).toBe(210);
|
||||
expect(result.byLevel).toEqual(
|
||||
[1, 2, 3, 4, 5].map((level) => ({
|
||||
level,
|
||||
total: 2,
|
||||
correct: level === 2 ? 1 : 2,
|
||||
total: level === 1 ? 3 : 0,
|
||||
correct: level === 1 ? 2 : 0,
|
||||
})),
|
||||
);
|
||||
expect(gameReducer(state, { type: "PLAY" })).toBe(state);
|
||||
});
|
||||
});
|
||||
|
||||
// 시작 단계는 라운드 계약이며 난수나 문항 인덱스가 이를 덮어쓰면 안 된다.
|
||||
it("시작 단계 1~5를 명시하면 세 문항 모두 같은 level/db이고 범위 밖 단계도 안전하다", () => {
|
||||
for (const level of LEVELS) {
|
||||
const questions = createQuestions(42, "mixed", "rotate", level.id);
|
||||
expect(questions.map((q) => [q.level, q.db])).toEqual(
|
||||
Array(3).fill([level.id, level.db]),
|
||||
);
|
||||
}
|
||||
for (const [input, expected] of [
|
||||
[NaN, 1],
|
||||
[-4, 1],
|
||||
[99, 5],
|
||||
[2.7, 3],
|
||||
])
|
||||
expect(createQuestions(1, "choice", "cafe", input)[0]!.level).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
// SNR 변경은 과거 채점의 청취 조건을 보존하고 현재/미응답 문항에만 전파한다.
|
||||
it("SNR을 정수 -12~12 dB로 제한하고 현재/남은 문항에 일관되게 적용한다", () => {
|
||||
let state = initialGame(createQuestions(42, "choice", "street", 2));
|
||||
for (const [input, expected] of [
|
||||
[-99, -12],
|
||||
[99, 12],
|
||||
[NaN, 3],
|
||||
[1.6, 2],
|
||||
]) {
|
||||
const adjusted = gameReducer(state, { type: "SNR", db: input! });
|
||||
expect(adjusted.questions.map((q) => q.db)).toEqual([
|
||||
expected,
|
||||
expected,
|
||||
expected,
|
||||
]);
|
||||
}
|
||||
for (const type of ["PLAY", "STARTED", "ENDED"] as const)
|
||||
state = gameReducer(state, { type });
|
||||
state = gameReducer(state, {
|
||||
type: "ANSWER",
|
||||
input: state.questions[0]!.word.answer,
|
||||
});
|
||||
state = gameReducer(state, { type: "SNR", db: -4 });
|
||||
expect(state.questions.map((q) => q.db)).toEqual([3, -4, -4]);
|
||||
expect(state.answers[0]).toMatchObject({ db: 3 });
|
||||
});
|
||||
|
||||
// 종료 뒤 지연 이벤트가 라운드를 되살리지 않는지 모든 단계에서 확인한다.
|
||||
it.each(["ready", "loading", "playing", "answer", "feedback"] as const)(
|
||||
"%s에서 중도 종료해도 무응답을 더하지 않고 종료 결과를 유지한다",
|
||||
(phase) => {
|
||||
const initial = initialGame(createQuestions(1, "choice"));
|
||||
const ended = gameReducer({ ...initial, phase }, { type: "EXIT" });
|
||||
expect(ended).toMatchObject({
|
||||
phase: "results",
|
||||
endedEarly: true,
|
||||
answers: [],
|
||||
});
|
||||
expect(summarize(ended)).toMatchObject({
|
||||
answered: 0,
|
||||
correct: 0,
|
||||
accuracy: 0,
|
||||
});
|
||||
for (const action of [
|
||||
{ type: "PLAY" },
|
||||
{ type: "STARTED" },
|
||||
{ type: "ENDED" },
|
||||
{ type: "NEXT" },
|
||||
{ type: "EXIT" },
|
||||
{ type: "ANSWER", input: "사과" },
|
||||
{ type: "ERROR", message: "late" },
|
||||
{ type: "SNR", db: 5 },
|
||||
] as const)
|
||||
expect(gameReducer(ended, action)).toBe(ended);
|
||||
},
|
||||
);
|
||||
|
||||
// 재생 중 SNR을 바꾸면 현재 음원에는 아직 적용되지 않는다. 결과의 dB는 실제로
|
||||
// 마지막 완료한 청취 조건을 기록해야 다음 재생용 선택값을 들었다고 오기하지 않는다.
|
||||
it("재생 중 변경된 SNR은 재청취 전 답변의 청취 이력을 덮어쓰지 않는다", () => {
|
||||
let state = initialGame(createQuestions(42, "choice"));
|
||||
state = gameReducer(state, { type: "PLAY" });
|
||||
state = gameReducer(state, { type: "STARTED" });
|
||||
state = gameReducer(state, { type: "SNR", db: -10 });
|
||||
state = gameReducer(state, { type: "ENDED" });
|
||||
state = gameReducer(state, {
|
||||
type: "ANSWER",
|
||||
input: state.questions[0]!.word.answer,
|
||||
});
|
||||
expect(state.answers[0]!.db).toBe(6);
|
||||
expect(state.questions[1]!.db).toBe(-10);
|
||||
});
|
||||
|
||||
// 세 문항으로 줄여도 순환 환경이 앞의 세 장소에 갇히지 않아야 한다.
|
||||
it("순환 환경은 여러 라운드에서 여덟 장소 모두를 선택하고 라운드 내에는 중복되지 않는다", () => {
|
||||
const seen = new Set<string>();
|
||||
for (let seed = 0; seed < 100; seed++) {
|
||||
const questions = createQuestions(seed, "mixed");
|
||||
expect(new Set(questions.map((q) => q.environment.id)).size).toBe(3);
|
||||
questions.forEach((q) => seen.add(q.environment.id));
|
||||
}
|
||||
expect(seen.size).toBe(8);
|
||||
});
|
||||
|
||||
@@ -12,3 +12,19 @@ it("neural 생성기의 실패 보존·retry·PCM·비밀값 부재 계약", ()
|
||||
);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
}, 60_000);
|
||||
|
||||
// 콘텐츠에 URL 모듈이 추가되어도 Python 생성기가 사용하는 Node 직접 import가 유지돼야 한다.
|
||||
it("Node type stripping 경로에서 기존 101문장 원문을 읽을 수 있다", () => {
|
||||
const result = spawnSync(
|
||||
"node",
|
||||
[
|
||||
"--experimental-strip-types",
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"import {INTRO,PATTERNS,WORDS} from './src/content.ts'; console.log(INTRO && PATTERNS.length * WORDS.length + 1)",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("101");
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@ import { readFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
// 배포 표기와 재현 명령을 함께 고정한다. 과거 인수 기록은 검사·수정하지 않는다.
|
||||
it("0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다", () => {
|
||||
it("0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다", () => {
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
|
||||
expect.soft(pkg.version).toBe("0.1.1");
|
||||
expect.soft(lock.version).toBe("0.1.1");
|
||||
expect.soft(lock.packages[""].version).toBe("0.1.1");
|
||||
expect.soft(pkg.version).toBe("0.2.3");
|
||||
expect.soft(lock.version).toBe("0.2.3");
|
||||
expect.soft(lock.packages[""].version).toBe("0.2.3");
|
||||
for (const path of [
|
||||
"src/App.tsx",
|
||||
"index.html",
|
||||
@@ -16,7 +16,7 @@ it("0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다", () =>
|
||||
"LICENSE",
|
||||
"docs/AUDIO_PROVENANCE.md",
|
||||
]) {
|
||||
expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
|
||||
expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,3 +42,33 @@ it("고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를
|
||||
.soft(readFileSync("LICENSE", "utf8"))
|
||||
.toContain("does not claim exclusive rights to generated speech");
|
||||
});
|
||||
|
||||
// 현재 문서는 같은 릴리스를 설명하고 변경 이력은 이전 근거를 보존해야 한다.
|
||||
it("현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다", () => {
|
||||
for (const path of [
|
||||
"docs/ARCHITECTURE.md",
|
||||
"docs/DEPLOYMENT.md",
|
||||
"docs/WORDS.md",
|
||||
"public/audio/manifest.json",
|
||||
"src/audio/assets.ts",
|
||||
])
|
||||
expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
|
||||
const history = readFileSync("CHANGELOG.md", "utf8");
|
||||
for (const version of ["0.2.3", "0.1.1", "0.1.0"])
|
||||
expect(history).toContain(version);
|
||||
for (const change of [
|
||||
"SNR",
|
||||
"3문항",
|
||||
"중도 종료",
|
||||
"CC BY-SA",
|
||||
"Chrome",
|
||||
"회귀",
|
||||
])
|
||||
expect(history).toContain(change);
|
||||
});
|
||||
|
||||
it("README는 현재 리뷰 증거 경로와 정확한 라운드 보너스를 안내한다", () => {
|
||||
const doc = readFileSync("README.md", "utf8");
|
||||
expect.soft(doc).toContain(".agent/evidence-v0.2.3/review-fixes/");
|
||||
expect.soft(doc).toContain("문항별 최대 20점, 라운드 합계 최대 30점");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""현장 녹음 변환의 경계와 실제 FFmpeg 출력을 작은 PCM fixture로 검사한다."""
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import wave
|
||||
from array import array
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
|
||||
import ambience_audio as audio
|
||||
|
||||
|
||||
class AmbienceConversion(unittest.TestCase):
|
||||
"""긴 원본을 번들하지 않고 짧은 무클릭 파생본만 만드는 계약이다."""
|
||||
def test_convert_format_fade_normalization_and_reproducibility(self):
|
||||
"""스테레오 48kHz 입력을 실제로 내려 섞고 길이·양끝·안전 피크를 확인한다."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / "source.wav"
|
||||
target = Path(directory) / "target.wav"
|
||||
samples = array("h", (round(2000 * math.sin(i / 20)) for i in range(48000 * 2 * 14)))
|
||||
with wave.open(str(source), "wb") as stream:
|
||||
stream.setparams((2, 2, 48000, 0, "NONE", "not compressed"))
|
||||
stream.writeframes(samples.tobytes())
|
||||
first = audio.convert(source, target, 1, 12)
|
||||
self.assertEqual(first["duration"], 12)
|
||||
self.assertGreater(first["rms"], 0.003)
|
||||
self.assertLessEqual(first["peak"], 0.85)
|
||||
with wave.open(str(target)) as stream:
|
||||
self.assertEqual((stream.getframerate(), stream.getnchannels(), stream.getsampwidth()), (24000, 1, 2))
|
||||
output = array("h", stream.readframes(stream.getnframes()))
|
||||
self.assertEqual((output[0], output[-1]), (0, 0))
|
||||
self.assertEqual(first, audio.convert(source, target, 1, 12))
|
||||
with self.assertRaises(ValueError):
|
||||
audio.convert(source, target, 0, 120)
|
||||
|
||||
def test_silence_fails_before_publishing(self):
|
||||
"""무음을 정상 현장 녹음으로 포장하지 않는다."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / "silent.wav"
|
||||
target = Path(directory) / "target.wav"
|
||||
with wave.open(str(source), "wb") as stream:
|
||||
stream.setparams((1, 2, 24000, 0, "NONE", "not compressed"))
|
||||
stream.writeframes(bytes(24000 * 2 * 12))
|
||||
with self.assertRaises(ValueError):
|
||||
audio.convert(source, target, 0, 12)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_short_input_seek_retries_full_decode_without_padding_or_truncating(self):
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / "source.wav"
|
||||
target = Path(directory) / "target.wav"
|
||||
samples = array("h", (round(2000 * math.sin(i / 20)) for i in range(24000 * 14)))
|
||||
with wave.open(str(source), "wb") as stream:
|
||||
stream.setparams((1, 2, 24000, 0, "NONE", "not compressed"))
|
||||
stream.writeframes(samples.tobytes())
|
||||
expected = audio.convert(source, target, 1)
|
||||
target.unlink()
|
||||
real_run = subprocess.run
|
||||
calls = []
|
||||
def shortened_seek(command, **kwargs):
|
||||
calls.append(command)
|
||||
result = real_run(command, **kwargs)
|
||||
if len(calls) == 1: result.stdout = result.stdout[:-191 * 4]
|
||||
return result
|
||||
with patch.object(audio.subprocess, "run", side_effect=shortened_seek):
|
||||
actual = audio.convert(source, target, 1)
|
||||
self.assertEqual(expected["conversion"]["seekMethod"], "input seek")
|
||||
self.assertEqual(actual["conversion"]["seekMethod"], "full decode then sample trim")
|
||||
expected["conversion"]["seekMethod"] = actual["conversion"]["seekMethod"]
|
||||
self.assertEqual(actual, expected)
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertLess(calls[0].index("-ss"), calls[0].index("-i"))
|
||||
self.assertNotIn("-ss", calls[1])
|
||||
self.assertIn("aresample=24000,atrim=start_sample=24000:end_sample=312000,asetpts=N/SR/TB", calls[1])
|
||||
target.unlink()
|
||||
# A genuinely exhausted source must still fail; no silence padding is allowed.
|
||||
with self.assertRaises(ValueError): audio.convert(source, target, 3)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
|
||||
class SourceGuard(unittest.TestCase):
|
||||
"""승인 목록은 입력이며 생성기가 현재 파일로 덮어쓰지 못한다."""
|
||||
def setUp(self):
|
||||
import json
|
||||
from unittest.mock import Mock
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.sources = self.root / "sources"
|
||||
self.sources.mkdir()
|
||||
self.output = self.root / "ambience"
|
||||
self.output.mkdir()
|
||||
(self.output / "old").write_bytes(b"prior release")
|
||||
self.plan = json.loads((audio.ROOT / ".agent/AMBIENCE-SOURCES-v0.2.3.json").read_text())
|
||||
published = json.loads((audio.ROOT / "public/ambience/manifest.json").read_text())
|
||||
starts = {e["environmentId"]: e["conversion"]["startSeconds"] for e in published["files"]}
|
||||
for asset in self.plan["assets"]:
|
||||
environment = asset["environmentId"]
|
||||
asset.setdefault("selectedSegmentStartSeconds", starts[environment])
|
||||
extension = ".mp3" if environment in ("theater", "department") else ".flac" if environment == "rain" else ".ogg"
|
||||
source = self.sources / (environment + extension)
|
||||
source.write_bytes(environment.encode())
|
||||
asset["expectedSourceSha256"] = audio.digest(source)
|
||||
asset["sourceBytes"] = source.stat().st_size
|
||||
self.converter = Mock(side_effect=AssertionError("conversion must not begin"))
|
||||
|
||||
def test_all_sources_checked_before_first_conversion(self):
|
||||
import copy
|
||||
for damage in ("hash", "bytes", "missing", "duplicate", "inventory"):
|
||||
with self.subTest(damage=damage):
|
||||
plan = copy.deepcopy(self.plan)
|
||||
if damage == "hash": plan["assets"][-1]["expectedSourceSha256"] = "0" * 64
|
||||
if damage == "bytes": plan["assets"][-1]["sourceBytes"] += 1
|
||||
if damage == "missing": plan["assets"][-1].pop("expectedSourceSha256")
|
||||
if damage == "duplicate": plan["assets"][-1] = plan["assets"][0]
|
||||
if damage == "inventory": plan["assets"].pop()
|
||||
before = copy.deepcopy(plan)
|
||||
with self.assertRaises(ValueError):
|
||||
audio.generate(plan, self.sources, self.output, converter=self.converter)
|
||||
self.converter.assert_not_called()
|
||||
self.assertEqual(plan, before)
|
||||
self.assertEqual((self.output / "old").read_bytes(), b"prior release")
|
||||
|
||||
def test_invalid_segment_plan_fails_before_any_conversion_without_mutation(self):
|
||||
import copy
|
||||
cases = [("selectedSegmentStartSeconds", value) for value in
|
||||
(None, -1, True, "36", float("nan"), float("inf"), 1788.001)]
|
||||
cases += [("directMediaDurationSeconds", value) for value in
|
||||
(None, 0, True, "1800", float("nan"), float("inf"), 191.999)]
|
||||
for key, value in cases:
|
||||
with self.subTest(key=key, value=value):
|
||||
plan = copy.deepcopy(self.plan)
|
||||
if value is None: plan["assets"][-1].pop(key)
|
||||
else: plan["assets"][-1][key] = value
|
||||
before = copy.deepcopy(plan)
|
||||
with self.assertRaises(ValueError):
|
||||
audio.generate(plan, self.sources, self.output, converter=self.converter)
|
||||
self.converter.assert_not_called()
|
||||
self.assertEqual(plan, before)
|
||||
self.assertEqual((self.output / "old").read_bytes(), b"prior release")
|
||||
|
||||
|
||||
class AtomicPublication(unittest.TestCase):
|
||||
"""실제 디렉터리 교환과 장애 주입으로 이전 릴리스 보존을 확인한다."""
|
||||
def setUp(self):
|
||||
import json
|
||||
import shutil
|
||||
SourceGuard.setUp(self)
|
||||
self.manifest = json.loads((audio.ROOT / "public/ambience/manifest.json").read_text())
|
||||
self.info = {e["environmentId"]: e for e in self.manifest["files"]}
|
||||
self.stage_paths = []
|
||||
|
||||
def copy_pcm(source, destination, start):
|
||||
import copy
|
||||
self.stage_paths.append(destination)
|
||||
entry = self.info[source.stem]
|
||||
shutil.copyfile(audio.ROOT / "public/ambience" / entry["file"], destination)
|
||||
result = copy.deepcopy({k: entry[k] for k in ("sha256", "bytes", "duration", "rms", "peak", "conversion")})
|
||||
result["conversion"]["startSeconds"] = start
|
||||
return result
|
||||
self.converter = copy_pcm
|
||||
|
||||
def run_generate(self, converter=None):
|
||||
return audio.generate(self.plan, self.sources, self.output, converter=converter or self.converter)
|
||||
|
||||
def assert_prior(self):
|
||||
self.assertEqual({p.name: p.read_bytes() for p in self.output.iterdir()}, {"old": b"prior release"})
|
||||
|
||||
def test_atomic_success_removes_stale_files_and_stages_notice_on_same_filesystem(self):
|
||||
import json
|
||||
self.run_generate()
|
||||
expected = {f"{a['environmentId']}.wav" for a in self.plan["assets"]} | {"manifest.json", "AMBIENCE_NOTICES.md"}
|
||||
self.assertEqual({p.name for p in self.output.iterdir()}, expected)
|
||||
self.assertTrue(all(self.output.parent in p.parents for p in self.stage_paths))
|
||||
self.assertFalse((self.output.parent / "AMBIENCE_NOTICES.md").exists())
|
||||
manifest = json.loads((self.output / "manifest.json").read_text())
|
||||
notice = (self.output / "AMBIENCE_NOTICES.md").read_text()
|
||||
for entry in manifest["files"]:
|
||||
section = next(s for s in notice.split("## ") if s.startswith(f"{entry['displayName']} — {entry['environmentId']}\n"))
|
||||
for line in (f"Creator: {entry['creator']}", f"Source: {entry['pageUrl']}",
|
||||
f"Download: {entry['directUrl']}", f"License: {entry['license']} — {entry['licenseUrl']}",
|
||||
f"Output license: {entry['outputLicense']}", f"Changes: {entry['conversion']['operations']}"):
|
||||
self.assertIn(line, section)
|
||||
self.assertEqual(list(self.root.glob(".ambience-stage-*")), [])
|
||||
|
||||
def test_all_eight_assets_validated_before_publication(self):
|
||||
for damaged in self.info:
|
||||
with self.subTest(asset=damaged):
|
||||
def broken(source, destination, start):
|
||||
info = self.converter(source, destination, start)
|
||||
if source.stem == damaged:
|
||||
destination.write_bytes(b"broken WAV")
|
||||
return info
|
||||
with self.assertRaises(ValueError): self.run_generate(broken)
|
||||
self.assert_prior()
|
||||
|
||||
def test_plan_alone_controls_selection_and_is_preserved_in_manifest_and_notice(self):
|
||||
import copy
|
||||
from unittest.mock import Mock
|
||||
# Arbitrary fractional starts and an exact end boundary detect a hidden release map.
|
||||
for index, asset in enumerate(self.plan["assets"]):
|
||||
asset["selectedSegmentStartSeconds"] = index + 1.25
|
||||
self.plan["assets"][-1]["selectedSegmentStartSeconds"] = 1788
|
||||
before = copy.deepcopy(self.plan)
|
||||
plan_file = audio.ROOT / ".agent/AMBIENCE-SOURCES-v0.2.3.json"
|
||||
plan_bytes = plan_file.read_bytes()
|
||||
converter = Mock(side_effect=self.converter)
|
||||
manifest = self.run_generate(converter)
|
||||
self.assertEqual(self.plan, before)
|
||||
self.assertEqual(plan_file.read_bytes(), plan_bytes)
|
||||
notice = (self.output / "AMBIENCE_NOTICES.md").read_text()
|
||||
self.assertEqual(converter.call_count, 8)
|
||||
for asset, entry, call in zip(self.plan["assets"], manifest["files"], converter.call_args_list):
|
||||
start = asset["selectedSegmentStartSeconds"]
|
||||
self.assertEqual(call.args[2], start)
|
||||
self.assertEqual(entry["selectedSegmentStartSeconds"], start)
|
||||
self.assertEqual(entry["conversion"]["startSeconds"], start)
|
||||
self.assertIn(f"source seconds {start}–{start + 12}.", notice)
|
||||
|
||||
def test_conversion_cannot_override_approved_segment(self):
|
||||
def wrong_segment(source, destination, start):
|
||||
result = self.converter(source, destination, start)
|
||||
result["conversion"]["startSeconds"] = start + 1
|
||||
return result
|
||||
with self.assertRaises(ValueError): self.run_generate(wrong_segment)
|
||||
self.assert_prior()
|
||||
|
||||
def test_conversion_failure_and_keyboard_interrupt_preserve_prior_directory(self):
|
||||
for index in range(8):
|
||||
for error in (OSError, KeyboardInterrupt):
|
||||
with self.subTest(index=index, error=error):
|
||||
calls = []
|
||||
def broken(source, destination, start):
|
||||
calls.append(source)
|
||||
if len(calls) == index + 1: raise error("injected")
|
||||
return self.converter(source, destination, start)
|
||||
with self.assertRaises(error): self.run_generate(broken)
|
||||
self.assert_prior()
|
||||
|
||||
def test_manifest_and_notice_write_failures_preserve_prior_directory(self):
|
||||
from unittest.mock import patch
|
||||
write = Path.write_text
|
||||
for name in ("manifest.json", "AMBIENCE_NOTICES.md"):
|
||||
with self.subTest(file=name):
|
||||
def fail(path, *args, **kwargs):
|
||||
if path.name == name: raise OSError("write failure")
|
||||
return write(path, *args, **kwargs)
|
||||
with patch.object(Path, "write_text", fail):
|
||||
with self.assertRaises(OSError): self.run_generate()
|
||||
self.assert_prior()
|
||||
|
||||
def test_symlink_output_is_rejected_before_conversion(self):
|
||||
from unittest.mock import Mock
|
||||
for dangling in (False, True):
|
||||
with self.subTest(dangling=dangling):
|
||||
link = self.root / f"link-{dangling}"
|
||||
link.symlink_to(self.root / "missing" if dangling else self.output, target_is_directory=True)
|
||||
converter = Mock()
|
||||
with self.assertRaises(ValueError):
|
||||
audio.generate(self.plan, self.sources, link, converter=converter)
|
||||
converter.assert_not_called()
|
||||
self.assert_prior()
|
||||
|
||||
def test_staged_manifest_notice_and_inventory_tampering_are_rejected(self):
|
||||
from unittest.mock import patch
|
||||
import json
|
||||
validate = audio.validate_bundle
|
||||
for damage in ("manifest", "notice", "extra", "symlink", "metadata"):
|
||||
with self.subTest(damage=damage):
|
||||
def tamper(directory, plan):
|
||||
if damage == "manifest": (directory / "manifest.json").write_text("{}")
|
||||
if damage == "notice": (directory / "AMBIENCE_NOTICES.md").write_text("truncated")
|
||||
if damage == "extra": (directory / "original.ogg").write_bytes(b"extra")
|
||||
if damage == "symlink":
|
||||
(directory / "street.wav").unlink()
|
||||
(directory / "street.wav").symlink_to(audio.ROOT / "public/ambience/street.wav")
|
||||
if damage == "metadata":
|
||||
manifest = json.loads((directory / "manifest.json").read_text())
|
||||
manifest["files"][0]["rms"] = 0.5
|
||||
(directory / "manifest.json").write_text(json.dumps(manifest))
|
||||
return validate(directory, plan)
|
||||
with patch.object(audio, "validate_bundle", tamper):
|
||||
with self.assertRaises(ValueError): self.run_generate()
|
||||
self.assert_prior()
|
||||
|
||||
def test_exchange_failure_preserves_prior_directory(self):
|
||||
from unittest.mock import patch
|
||||
import ctypes
|
||||
libc = ctypes.CDLL(None)
|
||||
with patch.object(audio.ctypes, "CDLL") as mocked:
|
||||
mocked.return_value.renameat2.return_value = -1
|
||||
mocked.return_value.renameat2.argtypes = libc.renameat2.argtypes
|
||||
with self.assertRaises(OSError): self.run_generate()
|
||||
self.assert_prior()
|
||||
|
||||
def test_first_publication_and_real_exchange(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.output)
|
||||
self.run_generate()
|
||||
self.assertTrue((self.output / "AMBIENCE_NOTICES.md").is_file())
|
||||
(self.output / "stale.wav").write_bytes(b"stale")
|
||||
self.run_generate()
|
||||
self.assertFalse((self.output / "stale.wav").exists())
|
||||
|
||||
def test_sigterm_before_commit_preserves_prior_directory(self):
|
||||
import multiprocessing
|
||||
import signal
|
||||
from unittest.mock import patch
|
||||
context = multiprocessing.get_context("fork")
|
||||
reached = context.Event()
|
||||
def child():
|
||||
def pause(*args):
|
||||
reached.set()
|
||||
signal.pause()
|
||||
with patch.object(audio, "atomic_publish", pause): self.run_generate()
|
||||
process = context.Process(target=child)
|
||||
process.start()
|
||||
try:
|
||||
self.assertTrue(reached.wait(10), "validated stage must reach commit")
|
||||
process.terminate()
|
||||
process.join(5)
|
||||
self.assertEqual(process.exitcode, -signal.SIGTERM)
|
||||
self.assert_prior()
|
||||
finally:
|
||||
if process.is_alive(): process.kill()
|
||||
process.join()
|
||||
|
||||
def test_readers_observe_whole_directory_during_real_exchange(self):
|
||||
import os
|
||||
import threading
|
||||
staged = self.root / "ready"
|
||||
staged.mkdir()
|
||||
for directory, value in ((self.output, b"old"), (staged, b"new")):
|
||||
for name in ("manifest.json", "AMBIENCE_NOTICES.md", "street.wav"):
|
||||
(directory / name).write_bytes(value)
|
||||
stopped, started = threading.Event(), threading.Event()
|
||||
failures, observed = [], []
|
||||
def reader():
|
||||
while not stopped.is_set():
|
||||
try:
|
||||
# 한 번 연 디렉터리 fd는 교환 중에도 같은 세대의 snapshot을 가리킨다.
|
||||
fd = os.open(self.output, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
values = []
|
||||
for name in ("manifest.json", "AMBIENCE_NOTICES.md", "street.wav"):
|
||||
child = os.open(name, os.O_RDONLY, dir_fd=fd)
|
||||
with os.fdopen(child, "rb") as stream: values.append(stream.read())
|
||||
observed.append(values)
|
||||
if len(set(values)) != 1: failures.append(values)
|
||||
finally: os.close(fd)
|
||||
except OSError as error: failures.append(str(error))
|
||||
started.set()
|
||||
thread = threading.Thread(target=reader)
|
||||
thread.start()
|
||||
try:
|
||||
self.assertTrue(started.wait(5))
|
||||
for _ in range(100): audio.atomic_publish(staged, self.output)
|
||||
finally:
|
||||
stopped.set()
|
||||
thread.join(5)
|
||||
self.assertTrue(observed)
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_unsupported_atomic_exchange_preserves_prior_directory(self):
|
||||
from unittest.mock import patch
|
||||
with patch.object(audio.ctypes, "CDLL", return_value=object()):
|
||||
with self.assertRaises(OSError): self.run_generate()
|
||||
self.assert_prior()
|
||||
@@ -75,3 +75,9 @@ describe("릴리스 문서 계약", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 명시적 아이콘이 없으면 Chrome이 /favicon.ico를 요청하여 정상 실행 콘솔에 404를 남긴다.
|
||||
it("HTML은 같은 문서의 내장 아이콘을 제공해 누락 favicon 요청을 막는다", () => {
|
||||
const html = readFileSync("index.html", "utf8");
|
||||
expect(html).toMatch(/<link rel="icon" href="data:image\/svg\+xml,/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user