@wenathlan/cadria 2.0.93 → 2.0.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +33 -1
  2. package/docs/.gitkeep +0 -0
  3. package/package.json +25 -2
  4. package/tests/.gitkeep +0 -0
  5. package/tests/audiodecode.test.ts +406 -0
  6. package/tests/audiofeatures.test.ts +271 -0
  7. package/tests/audiopipeline.test.ts +193 -0
  8. package/tests/audiorhythm.test.ts +172 -0
  9. package/tests/audiospectrum.test.ts +257 -0
  10. package/tests/audiostructure.test.ts +292 -0
  11. package/tests/audiotimbre.test.ts +270 -0
  12. package/tests/audiotonality.test.ts +289 -0
  13. package/tests/blendmodes.test.ts +31 -4
  14. package/tests/colorconvert.test.ts +3 -4
  15. package/tests/easecurves.test.ts +3 -4
  16. package/tests/family.test.ts +73 -0
  17. package/tests/familyurl.test.ts +46 -0
  18. package/tests/gateway.test.ts +373 -0
  19. package/tests/generation.test.ts +228 -0
  20. package/tests/imageexport.test.ts +443 -0
  21. package/tests/imageproject.test.ts +372 -0
  22. package/tests/imagerender.test.ts +291 -0
  23. package/tests/imagerenderpaint.test.ts +119 -0
  24. package/tests/imagestyles.test.ts +347 -0
  25. package/tests/masks.test.ts +3 -3
  26. package/tests/matiz.test.ts +8 -5
  27. package/tests/mediaprobe.test.ts +66 -22
  28. package/tests/onboardingstate.test.ts +73 -0
  29. package/tests/shadercomp.test.ts +120 -0
  30. package/tests/synthcomposition.test.ts +257 -0
  31. package/tests/synthmotion.test.ts +372 -0
  32. package/tests/synthpalette.test.ts +247 -0
  33. package/tests/synthtexture.test.ts +256 -0
  34. package/tests/timelineedit.test.ts +3 -6
  35. package/tests/timelineview.test.ts +248 -0
  36. package/tests/timeticks.test.ts +4 -4
  37. package/tests/transcodeplan.test.ts +19 -8
  38. package/tests/versawaseanimate.test.ts +106 -0
  39. package/tests/versawasescheduler.test.ts +96 -0
  40. package/tests/versawaseslices.test.ts +75 -0
  41. package/tests/visualize.test.ts +6 -6
@@ -0,0 +1,73 @@
1
+ // # family.test — the family table the chrome rides, runnable with vitest:
2
+ // pnpm vitest run tests/family.test.ts
3
+ // Every test watches the promise the row makes: the eight siblings beside
4
+ // cadria are listed once, every slug resolves through the familyurl contract
5
+ // into a climb-one-level url, every member carries the spec §12 accent with
6
+ // its own dot ink, and no accent ever collides with the banned palette.
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "vitest";
9
+ import { FAMILY_ACCENTS, FAMILY_SLUGS, familyrow } from "../family.ts";
10
+ import { familyurl } from "../familyurl.ts";
11
+
12
+ const BANNED = new Set(["#3b82f6", "#2563eb", "#f97316"]);
13
+
14
+ describe("family table", () => {
15
+ it("lists the eight siblings beside cadria, never cadria itself", () => {
16
+ assert.deepEqual(FAMILY_SLUGS, [
17
+ "argan",
18
+ "debonair",
19
+ "forge",
20
+ "foundry",
21
+ "getry",
22
+ "saddle",
23
+ "stealthhead",
24
+ "vault",
25
+ ]);
26
+ assert.ok(!FAMILY_SLUGS.includes("cadria"));
27
+ assert.ok(!FAMILY_SLUGS.includes("devthink"));
28
+ });
29
+
30
+ it("answers an accent for every listed sibling", () => {
31
+ for (const slug of FAMILY_SLUGS) {
32
+ const member = FAMILY_ACCENTS[slug];
33
+ assert.ok(member, `${slug} must carry an accent`);
34
+ assert.equal(member.slug, slug);
35
+ assert.match(member.accent, /^#[0-9a-f]{6}$/);
36
+ assert.match(member.dotInk, /^#[0-9a-f]{6}$/);
37
+ }
38
+ // the table holds exactly the siblings — no orphan accents
39
+ assert.deepEqual(Object.keys(FAMILY_ACCENTS).sort(), [...FAMILY_SLUGS].sort());
40
+ });
41
+
42
+ it("resolves every member through the familyurl contract", () => {
43
+ for (const slug of FAMILY_SLUGS) {
44
+ assert.equal(familyurl(slug), `../${slug}/`);
45
+ }
46
+ });
47
+
48
+ it("keeps the spec §12 accents stable", () => {
49
+ assert.equal(FAMILY_ACCENTS.argan.accent, "#34d8a8");
50
+ assert.equal(FAMILY_ACCENTS.debonair.accent, "#d9962e");
51
+ assert.equal(FAMILY_ACCENTS.forge.accent, "#a2cb3a");
52
+ assert.equal(FAMILY_ACCENTS.foundry.accent, "#2fb8b5");
53
+ assert.equal(FAMILY_ACCENTS.getry.accent, "#8b5cf6");
54
+ assert.equal(FAMILY_ACCENTS.saddle.accent, "#e86f2d");
55
+ assert.equal(FAMILY_ACCENTS.stealthhead.accent, "#e8563f");
56
+ assert.equal(FAMILY_ACCENTS.vault.accent, "#a3d7e6");
57
+ });
58
+
59
+ it("never ships a banned hue in the row", () => {
60
+ for (const member of familyrow()) {
61
+ assert.ok(!BANNED.has(member.accent), `${member.slug} rides a banned accent`);
62
+ }
63
+ });
64
+
65
+ it("builds the rail row in slug order without gaps", () => {
66
+ const row = familyrow();
67
+ assert.equal(row.length, FAMILY_SLUGS.length);
68
+ assert.deepEqual(
69
+ row.map((member) => member.slug),
70
+ [...FAMILY_SLUGS],
71
+ );
72
+ });
73
+ });
@@ -0,0 +1,46 @@
1
+ // # familyurl.test — the family link resolver of the rail foot: the relative
2
+ // sibling path the window chrome builds for the other deploy units of the
3
+ // family, runnable with vitest:
4
+ // pnpm vitest run tests/familyurl.test.ts
5
+ // Every test watches the contract the rail promises: the url always climbs
6
+ // one level into the sibling folder, is slash-terminated, survives slugs
7
+ // that arrive decorated with slashes and answers for every member of the
8
+ // family.
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "vitest";
11
+ import { familyurl } from "../familyurl.ts";
12
+
13
+ describe("familyurl", () => {
14
+ it("resolves a bare slug into the sibling folder", () => {
15
+ assert.equal(familyurl("devthink"), "../devthink/");
16
+ assert.equal(familyurl("stealthhead"), "../stealthhead/");
17
+ assert.equal(familyurl("debonair"), "../debonair/");
18
+ assert.equal(familyurl("getry"), "../getry/");
19
+ });
20
+
21
+ it("strips decorative slashes from the slug", () => {
22
+ assert.equal(familyurl("/devthink"), "../devthink/");
23
+ assert.equal(familyurl("devthink/"), "../devthink/");
24
+ assert.equal(familyurl("/debonair/"), "../debonair/");
25
+ assert.equal(familyurl("//forge//"), "../forge/");
26
+ });
27
+
28
+ it("keeps the relative form stable for every member of the family", () => {
29
+ for (const slug of [
30
+ "devthink",
31
+ "stealthhead",
32
+ "debonair",
33
+ "argan",
34
+ "saddle",
35
+ "forge",
36
+ "foundry",
37
+ "vault",
38
+ "getry",
39
+ ]) {
40
+ const url = familyurl(slug);
41
+ assert.ok(url.startsWith("../"), `${url} must climb one deploy unit`);
42
+ assert.ok(url.endsWith("/"), `${url} must be slash-terminated`);
43
+ assert.equal(url, `../${slug}/`);
44
+ }
45
+ });
46
+ });
@@ -0,0 +1,373 @@
1
+ // # gateway.test — http surface tests for the cadria gateway (task 9-3-c2),
2
+ // runnable with vitest:
3
+ // pnpm vitest run tests/gateway.test.ts
4
+ // Every suite rides hono's app.request pattern over an in-memory
5
+ // GenerationStore stub — proving the gateway leans on the storage seam
6
+ // contract only, never on sqlite/generation.ts. The wav fixture synthesizes a
7
+ // 4-second song the same way tests/audiopipeline.test.ts does (tone +
8
+ // wavBytes helpers, no binary files on disk).
9
+
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "vitest";
12
+ import type { Hono } from "hono";
13
+ import { type AnalysisRecord, createGateway, type GenerationRecord, type GenerationStore } from "../gateway.ts";
14
+
15
+ const SAMPLE_RATE = 48000;
16
+
17
+ // ---- fixture: steady 120bpm kick spine under saw intro, hats, triad, pad ----
18
+
19
+ function tone(
20
+ track: Float32Array,
21
+ sampleRate: number,
22
+ startMs: number,
23
+ durationMs: number,
24
+ hz: number,
25
+ gain = 0.6,
26
+ kind: "sine" | "saw" = "sine",
27
+ ): void {
28
+ const start = Math.round((startMs / 1000) * sampleRate);
29
+ const length = Math.round((durationMs / 1000) * sampleRate);
30
+ for (let i = 0; i < length && start + i < track.length; i++) {
31
+ const phase = (hz * i) / sampleRate;
32
+ const value = kind === "saw" ? 2 * (phase - Math.floor(phase + 0.5)) : Math.sin(2 * Math.PI * phase);
33
+ const fade = i < length * 0.1 ? i / (length * 0.1) : i > length * 0.85 ? (length - i) / (length * 0.15) : 1;
34
+ track[start + i] += gain * value * fade;
35
+ }
36
+ }
37
+
38
+ function songFixture(): Float32Array {
39
+ const track = new Float32Array(4 * SAMPLE_RATE);
40
+ for (let t = 0; t < 4000; t += 500) tone(track, SAMPLE_RATE, t, 120, 60, 0.7);
41
+ tone(track, SAMPLE_RATE, 0, 1000, 110, 0.5, "saw");
42
+ for (const hz of [261.63, 329.63, 392.0]) tone(track, SAMPLE_RATE, 2000, 1000, hz, 0.4);
43
+ for (const [i, hz] of [220, 196, 174.61].entries())
44
+ tone(track, SAMPLE_RATE, 2000 + i * 120, 1000 - i * 120, hz, 0.45 - i * 0.12);
45
+ return track;
46
+ }
47
+
48
+ /** wraps pcm into a minimal 16-bit mono wav byte buffer (RIFF header). */
49
+ function wavBytes(samples: Float32Array, sampleRate: number): Uint8Array<ArrayBuffer> {
50
+ const pcm = new Int16Array(samples.length);
51
+ for (let i = 0; i < samples.length; i++) {
52
+ const clamped = Math.max(-1, Math.min(1, samples[i]));
53
+ pcm[i] = Math.round(clamped * 32767);
54
+ }
55
+ const bytes = new Uint8Array(44 + pcm.length * 2);
56
+ const view = new DataView(bytes.buffer);
57
+ const text = (offset: number, value: string): void => {
58
+ for (let i = 0; i < value.length; i++) view.setUint8(offset + i, value.charCodeAt(i));
59
+ };
60
+ text(0, "RIFF");
61
+ view.setUint32(4, 36 + pcm.length * 2, true);
62
+ text(8, "WAVE");
63
+ text(12, "fmt ");
64
+ view.setUint32(16, 16, true);
65
+ view.setUint16(20, 1, true);
66
+ view.setUint16(22, 1, true);
67
+ view.setUint32(24, sampleRate, true);
68
+ view.setUint32(28, sampleRate * 2, true);
69
+ view.setUint16(32, 2, true);
70
+ view.setUint16(34, 16, true);
71
+ text(36, "data");
72
+ view.setUint32(40, pcm.length * 2, true);
73
+ bytes.set(new Uint8Array(pcm.buffer), 44);
74
+ return bytes;
75
+ }
76
+
77
+ // ---- seam stub: the in-memory GenerationStore the gateway is coded against ----
78
+
79
+ function stubStore(): GenerationStore & { generations: GenerationRecord[]; analyses: AnalysisRecord[] } {
80
+ const generations: GenerationRecord[] = [];
81
+ const analyses: AnalysisRecord[] = [];
82
+ return {
83
+ generations,
84
+ analyses,
85
+ saveGeneration(record) {
86
+ const at = generations.findIndex((row) => row.id === record.id);
87
+ if (at >= 0) generations.splice(at, 1);
88
+ generations.unshift(record);
89
+ return { ok: true };
90
+ },
91
+ getGeneration(id) {
92
+ return generations.find((row) => row.id === id) ?? null;
93
+ },
94
+ listGenerations(limit) {
95
+ return generations.slice(0, Math.max(0, Math.floor(limit)));
96
+ },
97
+ deleteGeneration(id) {
98
+ const at = generations.findIndex((row) => row.id === id);
99
+ if (at >= 0) {
100
+ generations.splice(at, 1);
101
+ return true;
102
+ }
103
+ return false;
104
+ },
105
+ saveAnalysis(record) {
106
+ const at = analyses.findIndex((row) => row.id === record.id);
107
+ if (at >= 0) analyses.splice(at, 1);
108
+ analyses.unshift(record);
109
+ return { ok: true };
110
+ },
111
+ getAnalysis(id) {
112
+ return analyses.find((row) => row.id === id) ?? null;
113
+ },
114
+ listAnalyses(limit) {
115
+ return analyses.slice(0, Math.max(0, Math.floor(limit)));
116
+ },
117
+ };
118
+ }
119
+
120
+ const jsonPost = (_path: string, body: unknown | string): RequestInit => ({
121
+ method: "POST",
122
+ headers: { "content-type": "application/json" },
123
+ body: typeof body === "string" ? body : JSON.stringify(body),
124
+ });
125
+
126
+ describe("gateway routes", () => {
127
+ const store = stubStore();
128
+ const app: Hono = createGateway(store);
129
+ let analysisId = "";
130
+ let projectId = "";
131
+
132
+ it("answers health with ok + service", async () => {
133
+ const response = await app.request("/api/health");
134
+ assert.equal(response.status, 200);
135
+ assert.deepEqual(await response.json(), { ok: true, service: "cadria-gateway" });
136
+ });
137
+
138
+ it("analyzes wav bytes over application/octet-stream and persists the record", async () => {
139
+ const response = await app.request("/api/analyze", {
140
+ method: "POST",
141
+ headers: { "content-type": "application/octet-stream" },
142
+ body: wavBytes(songFixture(), SAMPLE_RATE),
143
+ });
144
+ assert.equal(response.status, 201);
145
+ const payload = (await response.json()) as {
146
+ analysisId: string;
147
+ report: { schemaVersion: number; source: { kind: string } };
148
+ };
149
+ assert.match(payload.analysisId, /^ana-[0-9a-f]{8}$/);
150
+ assert.equal(payload.report.schemaVersion, 1);
151
+ assert.equal(payload.report.source.kind, "wav");
152
+ analysisId = payload.analysisId;
153
+ const stored = store.getAnalysis(analysisId);
154
+ assert.ok(stored, "the store received the analysis record");
155
+ assert.equal(stored?.sourceKind, "wav");
156
+ assert.ok(stored?.reportJson.includes("descriptor"));
157
+ });
158
+
159
+ it("analyzes pcm samples over json too", async () => {
160
+ const samples = songFixture();
161
+ const response = await app.request(
162
+ "/api/analyze",
163
+ jsonPost("/api/analyze", {
164
+ samplesBase64: Buffer.from(samples.buffer).toString("base64"),
165
+ sampleRate: SAMPLE_RATE,
166
+ }),
167
+ );
168
+ assert.equal(response.status, 201);
169
+ const payload = (await response.json()) as { report: { source: { kind: string } } };
170
+ assert.equal(payload.report.source.kind, "pcm");
171
+ assert.ok(store.analyses.length >= 2);
172
+ });
173
+
174
+ it("generates a project from an analysisId", async () => {
175
+ const response = await app.request("/api/generate", jsonPost("/api/generate", { analysisId }));
176
+ assert.equal(response.status, 201);
177
+ const payload = (await response.json()) as {
178
+ projectId: string;
179
+ project: { version: number; seed: string; style: string };
180
+ digest: string;
181
+ };
182
+ assert.match(payload.projectId, /^gen-[0-9a-z]{8}-[0-9a-f]{8}$/);
183
+ assert.equal(payload.project.version, 1);
184
+ assert.ok(payload.project.seed.length > 0);
185
+ assert.match(payload.digest, /^[0-9a-f]{8}$/);
186
+ projectId = payload.projectId;
187
+ const stored = store.getGeneration(projectId);
188
+ assert.ok(stored, "the store received the generation record");
189
+ assert.equal(stored?.style, payload.project.style);
190
+ });
191
+
192
+ it("generates from a bare descriptor with a named style", async () => {
193
+ const report = JSON.parse(store.getAnalysis(analysisId)?.reportJson ?? "null") as { descriptor: unknown };
194
+ const response = await app.request(
195
+ "/api/generate",
196
+ jsonPost("/api/generate", { descriptor: report.descriptor, style: "nocturn" }),
197
+ );
198
+ assert.equal(response.status, 201);
199
+ const payload = (await response.json()) as {
200
+ project: { style: string; canvas: { width: number; height: number } };
201
+ };
202
+ assert.equal(payload.project.style, "nocturn");
203
+ assert.equal(payload.project.canvas.width, 1080);
204
+ });
205
+
206
+ it("renders the stored project as byte-deterministic svg", async () => {
207
+ const first = await app.request(`/api/render/${projectId}`);
208
+ assert.equal(first.status, 200);
209
+ assert.ok((first.headers.get("content-type") ?? "").startsWith("image/svg+xml"));
210
+ const svg = await first.text();
211
+ assert.ok(svg.startsWith("<svg") || svg.startsWith("<?xml"), `svg head: ${svg.slice(0, 40)}`);
212
+ const second = await app.request(`/api/render/${projectId}`);
213
+ assert.equal(second.status, 200);
214
+ assert.equal(await second.text(), svg, "two render requests must be byte-equal");
215
+ });
216
+
217
+ it("lists projects as metadata without the heavy json fields", async () => {
218
+ const response = await app.request("/api/projects");
219
+ assert.equal(response.status, 200);
220
+ const payload = (await response.json()) as { projects: Record<string, unknown>[]; count: number };
221
+ assert.ok(payload.projects.length >= 2);
222
+ assert.equal(payload.count, payload.projects.length);
223
+ const row = payload.projects[0];
224
+ for (const key of ["id", "seed", "style", "bpm", "keyTonic", "keyMode", "key", "durationMs", "createdAt"]) {
225
+ assert.ok(key in row, `metadata carries ${key}`);
226
+ }
227
+ for (const heavy of ["descriptorJson", "projectJson", "svgDigest"]) {
228
+ assert.ok(!(heavy in row), `metadata must not carry ${heavy}`);
229
+ }
230
+ assert.ok(typeof row.bpm === "number" && typeof row.key === "string" && row.key.includes(" "));
231
+ });
232
+
233
+ it("deletes a stored project and answers ok with the id", async () => {
234
+ const response = await app.request(`/api/projects/${projectId}`, { method: "DELETE" });
235
+ assert.equal(response.status, 200);
236
+ assert.deepEqual(await response.json(), { ok: true, id: projectId });
237
+ const after = await app.request(`/api/render/${projectId}`);
238
+ assert.equal(after.status, 404);
239
+ });
240
+
241
+ it("answers 404 gateway-unknown-project when deleting a missing generation", async () => {
242
+ const response = await app.request("/api/projects/gen-missing0000-00000000", { method: "DELETE" });
243
+ assert.equal(response.status, 404);
244
+ const payload = (await response.json()) as { ok: boolean; code: string };
245
+ assert.equal(payload.ok, false);
246
+ assert.equal(payload.code, "gateway-unknown-project");
247
+ });
248
+
249
+ it("lists analyses as metadata without reportJson", async () => {
250
+ const response = await app.request("/api/analyses");
251
+ assert.equal(response.status, 200);
252
+ const payload = (await response.json()) as { analyses: Record<string, unknown>[]; count: number };
253
+ assert.ok(payload.analyses.length >= 1);
254
+ const row = payload.analyses[0];
255
+ for (const key of ["id", "sourceKind", "sampleRate", "durationMs", "bpm", "keyName", "createdAt"]) {
256
+ assert.ok(key in row, `metadata carries ${key}`);
257
+ }
258
+ assert.ok(!("reportJson" in row), "metadata must not carry reportJson");
259
+ });
260
+
261
+ it("clamps the list limit into 1-200, defaulting when unreadable", async () => {
262
+ for (let i = store.generations.length; i < 205; i++) {
263
+ store.generations.push({
264
+ id: `gen-filler${String(i).padStart(3, "0")}-aaaaaaaa`,
265
+ seed: "filler",
266
+ style: "opaline",
267
+ bpm: 120,
268
+ keyTonic: 0,
269
+ keyMode: "major",
270
+ durationMs: 4000,
271
+ descriptorJson: "{}",
272
+ projectJson: "{}",
273
+ svgDigest: "00000000",
274
+ createdAt: "2026-01-01T00:00:00.000Z",
275
+ });
276
+ }
277
+ const high = (await (await app.request("/api/projects?limit=500")).json()) as { projects: unknown[] };
278
+ assert.equal(high.projects.length, 200, "limit folds down to the 200 ceiling");
279
+ const zero = (await (await app.request("/api/projects?limit=0")).json()) as { projects: unknown[] };
280
+ assert.equal(zero.projects.length, 1, "limit folds up to the 1 floor");
281
+ const unreadable = (await (await app.request("/api/projects?limit=abc")).json()) as { projects: unknown[] };
282
+ assert.equal(unreadable.projects.length, 50, "unreadable limit takes the default 50");
283
+ });
284
+ });
285
+
286
+ describe("gateway error mapping", () => {
287
+ it("answers 404 gateway-unknown-project for an unknown render id", async () => {
288
+ const response = await createGateway(stubStore()).request("/api/render/gen-deadbeef-00000000");
289
+ assert.equal(response.status, 404);
290
+ const payload = (await response.json()) as { ok: boolean; code: string };
291
+ assert.equal(payload.ok, false);
292
+ assert.equal(payload.code, "gateway-unknown-project");
293
+ });
294
+
295
+ it("answers 404 gateway-unknown-analysis for a dead analysisId", async () => {
296
+ const response = await createGateway(stubStore()).request(
297
+ "/api/generate",
298
+ jsonPost("/api/generate", { analysisId: "ana-00000000" }),
299
+ );
300
+ assert.equal(response.status, 404);
301
+ const payload = (await response.json()) as { code: string };
302
+ assert.equal(payload.code, "gateway-unknown-analysis");
303
+ });
304
+
305
+ it("maps a malformed json body to gateway-bad-json", async () => {
306
+ const response = await createGateway(stubStore()).request("/api/generate", jsonPost("/api/generate", "{not json"));
307
+ assert.equal(response.status, 400);
308
+ const payload = (await response.json()) as { code: string };
309
+ assert.equal(payload.code, "gateway-bad-json");
310
+ });
311
+
312
+ it("maps a body with no source to gateway-no-source", async () => {
313
+ const response = await createGateway(stubStore()).request("/api/generate", jsonPost("/api/generate", {}));
314
+ assert.equal(response.status, 400);
315
+ const payload = (await response.json()) as { code: string };
316
+ assert.equal(payload.code, "gateway-no-source");
317
+ });
318
+
319
+ it("rejects an unknown style name with style-unknown", async () => {
320
+ const response = await createGateway(stubStore()).request(
321
+ "/api/generate",
322
+ jsonPost("/api/generate", {
323
+ descriptor: { version: 1, seed: "x", vector: [], scalar: {} },
324
+ style: "nonexistent",
325
+ }),
326
+ );
327
+ assert.equal(response.status, 400);
328
+ const payload = (await response.json()) as { code: string };
329
+ assert.equal(payload.code, "style-unknown");
330
+ });
331
+
332
+ it("maps oversized bodies to 413 gateway-payload-too-large", async () => {
333
+ const response = await createGateway(stubStore()).request("/api/analyze", {
334
+ method: "POST",
335
+ headers: { "content-type": "application/octet-stream" },
336
+ body: new ArrayBuffer(8 * 1024 * 1024 + 1), // one byte past MAX_BODY_BYTES
337
+ });
338
+ assert.equal(response.status, 413);
339
+ const payload = (await response.json()) as { code: string };
340
+ assert.equal(payload.code, "gateway-payload-too-large");
341
+ });
342
+
343
+ it("maps a refusing store write to 502 { ok: false, gateway-store-error }", async () => {
344
+ const base = stubStore();
345
+ const app = createGateway({
346
+ ...base,
347
+ saveGeneration: () => ({ ok: false, error: "sqlite locked" }),
348
+ });
349
+ const analyzed = await app.request("/api/analyze", {
350
+ method: "POST",
351
+ headers: { "content-type": "application/octet-stream" },
352
+ body: wavBytes(songFixture(), SAMPLE_RATE),
353
+ });
354
+ const { analysisId } = (await analyzed.json()) as { analysisId: string };
355
+ const response = await app.request("/api/generate", jsonPost("/api/generate", { analysisId }));
356
+ assert.equal(response.status, 502);
357
+ const payload = (await response.json()) as { ok: boolean; code: string; error: string };
358
+ assert.equal(payload.ok, false);
359
+ assert.equal(payload.code, "gateway-store-error");
360
+ assert.equal(payload.error, "sqlite locked");
361
+ });
362
+
363
+ it("maps empty wav bytes onto the pipeline error taxonomy", async () => {
364
+ const response = await createGateway(stubStore()).request("/api/analyze", {
365
+ method: "POST",
366
+ headers: { "content-type": "application/octet-stream" },
367
+ body: new Uint8Array(0),
368
+ });
369
+ assert.equal(response.status, 400);
370
+ const payload = (await response.json()) as { code: string };
371
+ assert.equal(payload.code, "pipeline-empty-samples");
372
+ });
373
+ });