feat: release Find-Word v0.2.3

This commit is contained in:
oz
2026-10-14 10:01:46 +00:00
parent c92660b514
commit 812540c743
531 changed files with 103693 additions and 325 deletions
+239 -12
View File
@@ -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();
});
+354
View File
@@ -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]);
});
+2 -2
View File
@@ -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%");
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+18
View File
@@ -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"),
);
});
+135
View File
@@ -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([]);
});
+194
View File
@@ -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,
});
});
+42 -2
View File
@@ -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
View File
@@ -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 });
}
},
);
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
+16
View File
@@ -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");
});
+35 -5
View File
@@ -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점");
});
+371
View File
@@ -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()
+6
View File
@@ -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,/);
});