@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,228 @@
1
+ // # generation.test — the storage seam of the generation pipeline, runnable
2
+ // with vitest:
3
+ // pnpm vitest run tests/generation.test.ts
4
+ // Two documented patterns live in the prologue below: (1) the TEMP database is
5
+ // chosen by setting process.env.DATABASE_URL to file:./.tmp/generation.test.db
6
+ // BEFORE the dynamic import of the db modules — db.ts resolves the url once at
7
+ // module load, so static imports would hoist past it — and .tmp is wiped before
8
+ // and after the run; (2) db.ts imports './seed' extensionless (vite-style), so
9
+ // a resolve fallback hook (specifier + '.ts' on ERR_MODULE_NOT_FOUND) must be
10
+ // registered before the import. The suites watch: idempotent schema boot, the
11
+ // save/get round trip (deep-equal minus nothing), newest-first listing with the
12
+ // 1–200 limit clamp, delete truthiness, the analysis side of the seam, the
13
+ // 512 KiB JSON cap, id validation, bpm clamping, determinism of read after
14
+ // write, and isolation between the two tables.
15
+ import assert from "node:assert/strict";
16
+ import { rmSync } from "node:fs";
17
+ import { registerHooks } from "node:module";
18
+ import { afterAll, describe, it } from "vitest";
19
+ import type { AnalysisRecord, GenerationRecord } from "../generation.ts";
20
+
21
+ const TMP_DIR = ".tmp";
22
+ rmSync(TMP_DIR, { recursive: true, force: true });
23
+
24
+ registerHooks({
25
+ resolve(specifier, context, next) {
26
+ try {
27
+ return next(specifier, context);
28
+ } catch (error) {
29
+ const code = (error as { code?: string }).code;
30
+ const relative = specifier.startsWith("./") || specifier.startsWith("../");
31
+ const last = specifier.split("/").pop() ?? "";
32
+ if (code === "ERR_MODULE_NOT_FOUND" && relative && !last.includes(".")) {
33
+ return next(`${specifier}.ts`, context);
34
+ }
35
+ throw error;
36
+ }
37
+ },
38
+ });
39
+
40
+ process.env.DATABASE_URL = `file:./${TMP_DIR}/generation.test.db`;
41
+ const { ensureGenerationSchema, generationStore } = await import("../generation.ts");
42
+ const { db } = await import("../db.ts");
43
+
44
+ const store = generationStore();
45
+ const MAX_JSON = 512 * 1024;
46
+
47
+ afterAll(() => {
48
+ db.close();
49
+ rmSync(TMP_DIR, { recursive: true, force: true });
50
+ });
51
+
52
+ // one monotonic iso clock for every save, so newest-first stays exact even when
53
+ // several suites share the temp database (vitest runs the its in order).
54
+ const BASE = 1_700_000_000_000;
55
+ let step = 0;
56
+ function stamp(): string {
57
+ step += 1;
58
+ return new Date(BASE + step * 1_000).toISOString();
59
+ }
60
+
61
+ function generation(id: string, overrides: Partial<GenerationRecord> = {}): GenerationRecord {
62
+ return {
63
+ id,
64
+ seed: "9a7c1e2b44d0f836",
65
+ style: "opaline",
66
+ bpm: 124,
67
+ keyTonic: 9,
68
+ keyMode: "minor",
69
+ durationMs: 96000,
70
+ descriptorJson: JSON.stringify({ version: 1, seed: id }),
71
+ projectJson: JSON.stringify({ version: 1, width: 1080, height: 1080 }),
72
+ svgDigest: "d41d8cd98f00b204e9800998ecf8427e",
73
+ createdAt: stamp(),
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ function analysis(id: string, overrides: Partial<AnalysisRecord> = {}): AnalysisRecord {
79
+ return {
80
+ id,
81
+ sourceKind: "file",
82
+ sampleRate: 44100,
83
+ durationMs: 96000,
84
+ bpm: 98,
85
+ keyName: "A minor",
86
+ reportJson: JSON.stringify({ version: 1, bpm: 98, tonic: 9, minor: 1 }),
87
+ createdAt: stamp(),
88
+ ...overrides,
89
+ };
90
+ }
91
+
92
+ function ids(records: { id: string }[]): string[] {
93
+ return records.map((record) => record.id);
94
+ }
95
+
96
+ describe("generation schema boot", () => {
97
+ it("is idempotent across two boots (no throw, tables intact)", () => {
98
+ assert.doesNotThrow(() => {
99
+ ensureGenerationSchema();
100
+ ensureGenerationSchema();
101
+ });
102
+ assert.ok(store.listGenerations(1).length <= 1);
103
+ });
104
+ });
105
+
106
+ describe("generation records", () => {
107
+ it("saves and reads a generation back as a deep-equal round trip", () => {
108
+ const record = generation("trip-a");
109
+ assert.deepEqual(store.saveGeneration(record), { ok: true });
110
+ assert.deepEqual(store.getGeneration("trip-a"), record);
111
+ });
112
+
113
+ it("answers null for a missing generation id", () => {
114
+ assert.equal(store.getGeneration("trip-nope"), null);
115
+ });
116
+
117
+ it("lists generations newest-first", () => {
118
+ for (const id of ["order-a", "order-b", "order-c"]) store.saveGeneration(generation(id));
119
+ assert.deepEqual(ids(store.listGenerations(3)), ["order-c", "order-b", "order-a"]);
120
+ });
121
+
122
+ it("honours a positive limit over the newest-first order", () => {
123
+ assert.deepEqual(ids(store.listGenerations(2)), ["order-c", "order-b"]);
124
+ });
125
+
126
+ it("clamps a limit below 1 up to one row", () => {
127
+ assert.equal(store.listGenerations(0).length, 1);
128
+ assert.equal(store.listGenerations(-10).length, 1);
129
+ });
130
+
131
+ it("caps the limit at 200 rows", () => {
132
+ for (let i = 0; i < 205; i += 1) store.saveGeneration(generation(`bulk-${i}`));
133
+ const page = store.listGenerations(Number.MAX_SAFE_INTEGER);
134
+ assert.equal(page.length, 200);
135
+ assert.equal(page[0].id, "bulk-204");
136
+ });
137
+
138
+ it("deletes an existing id (true) then reports the miss (false)", () => {
139
+ store.saveGeneration(generation("del-a"));
140
+ assert.equal(store.deleteGeneration("del-a"), true);
141
+ assert.equal(store.getGeneration("del-a"), null);
142
+ assert.equal(store.deleteGeneration("del-a"), false);
143
+ assert.equal(store.deleteGeneration("del-nope"), false);
144
+ });
145
+
146
+ it("rejects oversized JSON payloads with ok:false and writes nothing", () => {
147
+ const fat = "x".repeat(MAX_JSON + 1);
148
+ const fatDescriptor = store.saveGeneration(generation("fat-descriptor", { descriptorJson: fat }));
149
+ assert.equal(fatDescriptor.ok, false);
150
+ if (!fatDescriptor.ok) assert.match(fatDescriptor.error, /descriptorJson/);
151
+ const fatProject = store.saveGeneration(generation("fat-project", { projectJson: fat }));
152
+ assert.equal(fatProject.ok, false);
153
+ assert.equal(store.getGeneration("fat-descriptor"), null);
154
+ assert.equal(store.getGeneration("fat-project"), null);
155
+ const thin = store.saveGeneration(generation("fat-edge", { descriptorJson: "x".repeat(MAX_JSON) }));
156
+ assert.deepEqual(thin, { ok: true });
157
+ });
158
+
159
+ it("rejects invalid ids on every accessor", () => {
160
+ assert.equal(store.saveGeneration(generation("")).ok, false);
161
+ assert.equal(store.saveGeneration(generation("x".repeat(129))).ok, false);
162
+ assert.equal(store.getGeneration(""), null);
163
+ assert.equal(store.getGeneration("x".repeat(129)), null);
164
+ assert.equal(store.deleteGeneration(""), false);
165
+ assert.equal(store.deleteGeneration("x".repeat(129)), false);
166
+ });
167
+
168
+ it("clamps bpm into 20–300 and refuses non-finite values", () => {
169
+ store.saveGeneration(generation("bpm-hi", { bpm: 999 }));
170
+ assert.equal(store.getGeneration("bpm-hi")?.bpm, 300);
171
+ store.saveGeneration(generation("bpm-lo", { bpm: 5 }));
172
+ assert.equal(store.getGeneration("bpm-lo")?.bpm, 20);
173
+ store.saveGeneration(generation("bpm-frac", { bpm: 123.6 }));
174
+ assert.equal(store.getGeneration("bpm-frac")?.bpm, 124);
175
+ const nan = store.saveGeneration(generation("bpm-nan", { bpm: Number.NaN }));
176
+ assert.equal(nan.ok, false);
177
+ const inf = store.saveGeneration(generation("bpm-inf", { bpm: Number.POSITIVE_INFINITY }));
178
+ assert.equal(inf.ok, false);
179
+ assert.equal(store.getGeneration("bpm-nan"), null);
180
+ });
181
+
182
+ it("reads back deterministically after write", () => {
183
+ const record = generation("det-a", { descriptorJson: JSON.stringify({ deep: { nested: [1, 2, 3] } }) });
184
+ store.saveGeneration(record);
185
+ const first = store.getGeneration("det-a");
186
+ const second = store.getGeneration("det-a");
187
+ assert.deepEqual(first, second);
188
+ assert.deepEqual(first, record);
189
+ assert.equal(typeof first?.createdAt, "string");
190
+ });
191
+ });
192
+
193
+ describe("analysis records", () => {
194
+ it("saves and reads an analysis back as a deep-equal round trip", () => {
195
+ const record = analysis("an-trip");
196
+ assert.deepEqual(store.saveAnalysis(record), { ok: true });
197
+ assert.deepEqual(store.getAnalysis("an-trip"), record);
198
+ });
199
+
200
+ it("lists analyses newest-first with the limit applied", () => {
201
+ for (const id of ["an-a", "an-b", "an-c"]) store.saveAnalysis(analysis(id));
202
+ assert.deepEqual(ids(store.listAnalyses(200)), ["an-c", "an-b", "an-a", "an-trip"]);
203
+ assert.deepEqual(ids(store.listAnalyses(2)), ["an-c", "an-b"]);
204
+ assert.equal(store.listAnalyses(0).length, 1);
205
+ });
206
+
207
+ it("rejects an oversized reportJson with ok:false", () => {
208
+ const result = store.saveAnalysis(analysis("an-fat", { reportJson: "y".repeat(MAX_JSON + 1) }));
209
+ assert.equal(result.ok, false);
210
+ if (!result.ok) assert.match(result.error, /reportJson/);
211
+ assert.equal(store.getAnalysis("an-fat"), null);
212
+ });
213
+ });
214
+
215
+ describe("isolation", () => {
216
+ it("keeps generations and analyses in separate tables", () => {
217
+ store.saveGeneration(generation("shared"));
218
+ store.saveAnalysis(analysis("shared"));
219
+ const gen = store.getGeneration("shared");
220
+ const an = store.getAnalysis("shared");
221
+ assert.ok(gen && "svgDigest" in gen);
222
+ assert.ok(an && "keyName" in an);
223
+ assert.equal(store.deleteGeneration("shared"), true);
224
+ assert.equal(store.getGeneration("shared"), null);
225
+ assert.equal(store.getAnalysis("shared")?.id, "shared");
226
+ assert.equal(store.deleteGeneration("shared"), false);
227
+ });
228
+ });
@@ -0,0 +1,443 @@
1
+ // # imageexport.test — the export/integration layer contract AND the wave-2
2
+ // end-to-end. Runnable with vitest:
3
+ // pnpm vitest run tests/imageexport.test.ts
4
+ // The fixture is a fully documented 32-dim AudioDescriptor (every value pinned
5
+ // to its audiofeatures.ts DIM_ORDER position), and the integration chain runs
6
+ // exactly what wave 2 shipped: synthPalette (b1) → compositionGrid /
7
+ // compositionBlocks + rhythmScatter (b2) → synthTexture (b3) → synthMotion
8
+ // (b4) → exportBundle (b8). b5 imagestyles (styleForDescriptor), b6 imageproject
9
+ // (buildImageProject) and b7 imagerender (renderSvg) had not landed when this
10
+ // suite was written — toExportProject stands in for buildImageProject at the
11
+ // documented structural contract (see imageexport.ts header). Every assertion
12
+ // is deterministic: no randomness, no clock, same inputs → same digests.
13
+ import assert from "node:assert/strict";
14
+ import { describe, it } from "vitest";
15
+ import type { AudioDescriptor } from "../audioattributes.ts";
16
+ import type { ExportProject } from "../imageexport.ts";
17
+ import {
18
+ ANIMATION_FPS,
19
+ buildAnimationManifest,
20
+ buildSvgArtifact,
21
+ ExportError,
22
+ exportBundle,
23
+ exportPlan,
24
+ fnv1aHex,
25
+ MANIFEST_FRAME_CAP,
26
+ PNG_COMPRESS_FACTOR,
27
+ PNG_SIZE_LADDER,
28
+ perturbFrame,
29
+ thumbnailSignature,
30
+ toExportProject,
31
+ } from "../imageexport.ts";
32
+ import { compositionBlocks, compositionGrid, rhythmScatter } from "../synthcomposition.ts";
33
+ import { synthMotion } from "../synthmotion.ts";
34
+ import { synthPalette } from "../synthpalette.ts";
35
+ import { synthTexture } from "../synthtexture.ts";
36
+
37
+ // ---- the documented 32-dim fixture (values per DIM_ORDER position) ----
38
+ // 0-6 spectral: centroid .42, spread .38, rolloff .55, flatness .18, flux .35,
39
+ // fluxVariance .28, brightness .60 — a bright, tonal, moving mix.
40
+ // 7-11 rhythm: tempo .50 (bpm pinned via scalar), pulseConfidence .82,
41
+ // onsetDensity .42 (→ 3.36 onsets/s), grooveRegularity .71, swing .58.
42
+ // 12-15 harmony: tonalCenter .75 (tonic 8 = g#), keyStrength .64,
43
+ // harmonicChange .40, dissonance .22 — a strong minor-leaning key.
44
+ // 16-21 timbre: noisiness .34, warmth .61, timbreBrightness .58, textureSlope
45
+ // .45, zeroCrossings .11, contrast .52 — warm mid mix, bold dynamics.
46
+ // 22-27 energy/structure: punch .66, duration .35, peakMass .72, repetition
47
+ // .42, narrativeContour .75 (arch), sectionDensity .60 — punchy arch build.
48
+ // 28-31 cross: brightnessPulse .30, energyDrive .68, bandTilt .40,
49
+ // moodShadow .35 — energetic drive, mild minor depth.
50
+ const FIXTURE_VECTOR: number[] = [
51
+ 0.42, 0.38, 0.55, 0.18, 0.35, 0.28, 0.6, 0.5, 0.82, 0.42, 0.71, 0.58, 0.75, 0.64, 0.4, 0.22, 0.34, 0.61, 0.58, 0.45,
52
+ 0.11, 0.52, 0.66, 0.35, 0.72, 0.42, 0.75, 0.6, 0.3, 0.68, 0.4, 0.35,
53
+ ];
54
+
55
+ /** the fused fixture descriptor: 32 dims above, bpm 120 → 500 ms motion loop. */
56
+ function fixture(): AudioDescriptor {
57
+ return { version: 1, seed: "b7e5a1c93d204f68", vector: [...FIXTURE_VECTOR], scalar: { bpm: 120, downbeat: 4 } };
58
+ }
59
+
60
+ /** runs the shipped wave-2 chain (b1 → b4) and hands the stack to b8's seam. */
61
+ function chainProject(id = "e2e-loop"): ExportProject {
62
+ const d = fixture();
63
+ const blocks = rhythmScatter(d, compositionBlocks(d, compositionGrid(d)));
64
+ return toExportProject(id, {
65
+ descriptor: d,
66
+ palette: synthPalette(d), // b1 imagestyles/styleForDescriptor lands upstream of this
67
+ blocks, // b2 compositionBlocks + rhythmScatter
68
+ texture: synthTexture(d), // b3
69
+ motion: synthMotion(
70
+ d,
71
+ blocks.map((b) => b.role),
72
+ ), // b4
73
+ });
74
+ }
75
+
76
+ /** asserts fn throws ExportError and answers its taxonomy code. */
77
+ function codeOf(fn: () => unknown): string {
78
+ try {
79
+ fn();
80
+ } catch (e) {
81
+ assert.ok(e instanceof ExportError, `expected ExportError, got ${String(e)}`);
82
+ return (e as ExportError).code;
83
+ }
84
+ assert.fail("expected ExportError, nothing thrown");
85
+ return "";
86
+ }
87
+
88
+ const HEX8 = /^[0-9a-f]{8}$/;
89
+
90
+ describe("wave-2 fixture + chain", () => {
91
+ it("fixture is a documented 32-dim descriptor", () => {
92
+ const d = fixture();
93
+ assert.equal(d.vector.length, 32);
94
+ for (const v of d.vector) assert.ok(Number.isFinite(v) && v >= 0 && v <= 1, `dim out of range: ${v}`);
95
+ assert.match(d.seed, /^[0-9a-f]{16}$/);
96
+ assert.equal(d.scalar.bpm, 120);
97
+ });
98
+
99
+ it("the full wave-2 chain runs deterministically end to end", () => {
100
+ const a = chainProject();
101
+ const b = chainProject();
102
+ assert.deepStrictEqual(a.palette, b.palette);
103
+ assert.deepStrictEqual(a.blocks, b.blocks);
104
+ assert.deepStrictEqual(a.texture, b.texture);
105
+ assert.deepStrictEqual(a.motion, b.motion);
106
+ const roles = new Set(a.blocks.map((x) => x.role));
107
+ for (const role of roles) assert.ok(["hero", "cadre", "field", "edge"].includes(role));
108
+ assert.ok(a.blocks.length >= 5); // hero + ring + fill from a 6-col grid
109
+ for (const x of a.blocks) {
110
+ for (const v of [x.rect.x, x.rect.y, x.rect.w, x.rect.h]) assert.ok(v >= 0 && v <= 1);
111
+ }
112
+ assert.equal(a.motion.loopMs, 500); // 120 bpm → one beat per 500 ms
113
+ });
114
+ });
115
+
116
+ describe("exportPlan", () => {
117
+ it("svg plan measures the real master and leaves timing null", () => {
118
+ const p = chainProject();
119
+ const plan = exportPlan(p, { format: "svg" });
120
+ assert.equal(plan.format, "svg");
121
+ assert.equal(plan.width, 1080);
122
+ assert.equal(plan.height, 1080);
123
+ assert.equal(plan.fps, null);
124
+ assert.equal(plan.loopMs, null);
125
+ assert.equal(plan.frames, null);
126
+ assert.ok(plan.qualityNote.length > 0);
127
+ assert.equal(plan.estimatedBytes, buildSvgArtifact(p, { width: 1080, height: 1080 }).bytes.length);
128
+ assert.ok(plan.estimatedBytes > 0);
129
+ });
130
+
131
+ it("png plan locks to the documented ladder and models RGBA deflate", () => {
132
+ const p = chainProject();
133
+ for (const rung of PNG_SIZE_LADDER) {
134
+ const plan = exportPlan(p, { format: "png", width: rung.width });
135
+ assert.equal(plan.width, rung.width);
136
+ assert.equal(plan.height, rung.height);
137
+ assert.equal(plan.estimatedBytes, Math.ceil(rung.width * rung.height * 4 * PNG_COMPRESS_FACTOR));
138
+ assert.equal(plan.fps, null);
139
+ }
140
+ assert.equal(
141
+ codeOf(() => exportPlan(p, { format: "png", width: 720 })),
142
+ "export-size",
143
+ );
144
+ assert.equal(
145
+ codeOf(() => exportPlan(p, { format: "png", width: 1440, height: 2160 })),
146
+ "export-size",
147
+ );
148
+ });
149
+
150
+ it("webm/gif require loopMs > 0 — export-loop-required", () => {
151
+ const p = chainProject();
152
+ assert.equal(
153
+ codeOf(() => exportPlan(p, { format: "webm", fps: 30 })),
154
+ "export-loop-required",
155
+ );
156
+ assert.equal(
157
+ codeOf(() => exportPlan(p, { format: "webm", fps: 30, loopMs: 0 })),
158
+ "export-loop-required",
159
+ );
160
+ assert.equal(
161
+ codeOf(() => exportPlan(p, { format: "gif", fps: 30, loopMs: -5 })),
162
+ "export-loop-required",
163
+ );
164
+ const plan = exportPlan(p, { format: "webm", fps: 30, loopMs: 500 });
165
+ assert.equal(plan.loopMs, 500);
166
+ assert.equal(plan.fps, 30);
167
+ assert.equal(plan.frames, 15); // round(500·30/1000)
168
+ assert.ok(plan.estimatedBytes > 0);
169
+ });
170
+
171
+ it("animation fps rungs are exactly 24/30/60 — export-format", () => {
172
+ const p = chainProject();
173
+ assert.deepStrictEqual([...ANIMATION_FPS], [24, 30, 60]);
174
+ assert.equal(
175
+ codeOf(() => exportPlan(p, { format: "webm", fps: 25, loopMs: 500 })),
176
+ "export-format",
177
+ );
178
+ assert.equal(
179
+ codeOf(() => exportPlan(p, { format: "gif", fps: 60.5, loopMs: 500 })),
180
+ "export-format",
181
+ );
182
+ assert.equal(
183
+ codeOf(() => exportPlan(p, { format: "webm", fps: 12, loopMs: 500 })),
184
+ "export-format",
185
+ );
186
+ assert.equal(exportPlan(p, { format: "webm", loopMs: 500 }).fps, 30); // documented default
187
+ assert.equal(exportPlan(p, { format: "webm", fps: 24, loopMs: 500 }).fps, 24);
188
+ });
189
+
190
+ it("unknown formats and wild sizes refuse", () => {
191
+ const p = chainProject();
192
+ assert.equal(
193
+ codeOf(() => exportPlan(p, { format: "jpeg" as never })),
194
+ "export-format",
195
+ );
196
+ assert.equal(
197
+ codeOf(() => exportPlan(p, { format: "mp4" as never })),
198
+ "export-format",
199
+ );
200
+ assert.equal(
201
+ codeOf(() => exportPlan(p, { format: "svg", width: 32 })),
202
+ "export-size",
203
+ );
204
+ assert.equal(
205
+ codeOf(() => exportPlan(p, { format: "svg", width: 8192 })),
206
+ "export-size",
207
+ );
208
+ assert.equal(
209
+ codeOf(() => exportPlan(p, { format: "webm", width: 8192, fps: 30, loopMs: 500 })),
210
+ "export-size",
211
+ );
212
+ assert.equal(
213
+ codeOf(() => exportPlan(p, { format: "svg", width: 800.5 })),
214
+ "export-size",
215
+ );
216
+ });
217
+
218
+ it("plans are deterministic and estimates finite + positive for every format", () => {
219
+ const p = chainProject();
220
+ for (const format of ["svg", "png", "webm", "gif"] as const) {
221
+ const options = { format, width: 1080, fps: 30, loopMs: 500 } as const;
222
+ const a = exportPlan(p, options);
223
+ const b = exportPlan(p, options);
224
+ assert.deepStrictEqual(a, b, `${format} plan drifted`);
225
+ assert.ok(Number.isFinite(a.estimatedBytes) && a.estimatedBytes > 0);
226
+ assert.ok(a.width > 0 && a.height > 0);
227
+ }
228
+ });
229
+ });
230
+
231
+ describe("svg master artifact", () => {
232
+ it("svg artifact: filename, well-formed bytes, fnv1a digest", () => {
233
+ const p = chainProject();
234
+ const a = buildSvgArtifact(p);
235
+ assert.equal(a.filename, "e2e-loop.svg");
236
+ assert.ok(a.bytes.startsWith('<svg xmlns="http://www.w3.org/2000/svg"'));
237
+ assert.ok(a.bytes.endsWith("</svg>"));
238
+ assert.equal(a.digest, fnv1aHex(a.bytes));
239
+ assert.match(a.digest, HEX8);
240
+ assert.ok(!a.bytes.includes("NaN") && !a.bytes.includes("undefined"));
241
+ const rects = a.bytes.split("<rect").length - 1;
242
+ assert.ok(rects >= p.blocks.length + 1); // every block + the surface ground
243
+ assert.ok(a.bytes.includes(p.palette.surface) && a.bytes.includes(p.palette.anchor));
244
+ });
245
+
246
+ it("svg artifact is byte-stable across runs and size-aware", () => {
247
+ const p = chainProject();
248
+ assert.deepStrictEqual(buildSvgArtifact(p), buildSvgArtifact(chainProject()));
249
+ const small = buildSvgArtifact(p, { width: 512, height: 384 });
250
+ assert.deepStrictEqual(small, buildSvgArtifact(p, { width: 512, height: 384 }));
251
+ assert.ok(small.bytes.startsWith('<svg xmlns="http://www.w3.org/2000/svg" width="512" height="384"'));
252
+ assert.notEqual(small.bytes, buildSvgArtifact(p).bytes);
253
+ });
254
+
255
+ it("export-empty: blank id, unreadable palette, empty cast", () => {
256
+ const p = chainProject();
257
+ assert.equal(
258
+ codeOf(() => toExportProject("", { descriptor: fixture() })),
259
+ "export-empty",
260
+ );
261
+ assert.equal(
262
+ codeOf(() => buildSvgArtifact({ ...p, blocks: [] })),
263
+ "export-empty",
264
+ );
265
+ const bad = { ...p, palette: { anchor: 1 } } as unknown as ExportProject;
266
+ assert.equal(
267
+ codeOf(() => buildSvgArtifact(bad)),
268
+ "export-empty",
269
+ );
270
+ });
271
+ });
272
+
273
+ describe("animation manifest + frame signatures", () => {
274
+ it("webm manifest: 15 even frames, sorted, strictly inside the loop", () => {
275
+ const p = chainProject();
276
+ const plan = exportPlan(p, { format: "webm", fps: 30, loopMs: 500 });
277
+ const m = buildAnimationManifest(p, plan);
278
+ assert.equal(m.fps, 30);
279
+ assert.equal(m.loopMs, 500);
280
+ assert.ok(m.frames.length <= MANIFEST_FRAME_CAP);
281
+ assert.equal(m.frames.length, 15);
282
+ m.frames.forEach((f, i) => {
283
+ assert.equal(f.index, i);
284
+ assert.match(f.signature, HEX8);
285
+ assert.ok(f.tMs >= 0 && f.tMs < 500);
286
+ });
287
+ for (let i = 1; i < m.frames.length; i += 1) assert.ok(m.frames[i].tMs > m.frames[i - 1].tMs);
288
+ assert.equal(new Set(m.frames.map((f) => f.signature)).size, m.frames.length);
289
+ });
290
+
291
+ it("manifest caps at exactly 128 frames for long loops", () => {
292
+ const p = chainProject();
293
+ const plan = exportPlan(p, { format: "gif", fps: 60, loopMs: 60000 });
294
+ assert.equal(plan.frames, MANIFEST_FRAME_CAP);
295
+ const m = buildAnimationManifest(p, plan);
296
+ assert.equal(m.frames.length, MANIFEST_FRAME_CAP);
297
+ for (let i = 1; i < m.frames.length; i += 1) assert.ok(m.frames[i].tMs > m.frames[i - 1].tMs);
298
+ assert.ok(m.frames[m.frames.length - 1].tMs < 60000);
299
+ });
300
+
301
+ it("manifest refuses static plans and loopless plans", () => {
302
+ const p = chainProject();
303
+ const svgPlan = exportPlan(p, { format: "svg" });
304
+ assert.equal(
305
+ codeOf(() => buildAnimationManifest(p, svgPlan)),
306
+ "export-format",
307
+ );
308
+ const webmPlan = exportPlan(p, { format: "webm", fps: 30, loopMs: 500 });
309
+ assert.equal(
310
+ codeOf(() => buildAnimationManifest(p, { ...webmPlan, loopMs: 0 })),
311
+ "export-loop-required",
312
+ );
313
+ });
314
+
315
+ it("perturbFrame: deterministic, resting camera at phase 0, riding the loop", () => {
316
+ const p = chainProject();
317
+ const p0 = perturbFrame(p, 0);
318
+ assert.equal(p0.phase, 0);
319
+ assert.equal(p0.dx, 0);
320
+ assert.equal(p0.dy, 0);
321
+ assert.equal(p0.rotate, 0);
322
+ assert.ok(Number.isFinite(p0.scale) && p0.scale > 0);
323
+ const pm = perturbFrame(p, 250);
324
+ assert.deepStrictEqual(perturbFrame(p, 250), pm); // deterministic
325
+ assert.equal(pm.phase, 0.5);
326
+ // arch contour (0.75) → drift {x:9, y:-5, rotation:1}, half-loop phase
327
+ assert.equal(pm.dx, 4.5);
328
+ assert.equal(pm.dy, -2.5);
329
+ assert.equal(pm.rotate, 0.5);
330
+ assert.ok(pm.scale > 1 && pm.scale < 1.2); // beat breath inside the documented band
331
+ });
332
+
333
+ it("thumbnailSignature: stable, 8-hex, sensitive to time and viewport", () => {
334
+ const p = chainProject();
335
+ const s0 = thumbnailSignature(p, 0);
336
+ assert.equal(s0, thumbnailSignature(p, 0));
337
+ assert.match(s0, HEX8);
338
+ assert.notEqual(thumbnailSignature(p, 250), s0);
339
+ assert.notEqual(thumbnailSignature(p, 0, 512, 384), s0);
340
+ });
341
+ });
342
+
343
+ describe("exportBundle — the wave-2 end-to-end", () => {
344
+ const opts = { format: ["svg", "png", "webm"] as const, width: 1080, fps: 30, loopMs: 500 };
345
+
346
+ it("E2E bundle: one svg master always ships plus each requested format", () => {
347
+ const b = exportBundle(chainProject(), opts);
348
+ assert.deepStrictEqual(
349
+ b.artifacts.map((a) => a.kind),
350
+ ["svg", "png", "webm"],
351
+ );
352
+ assert.deepStrictEqual(
353
+ b.artifacts.map((a) => a.filename),
354
+ ["e2e-loop.svg", "e2e-loop.png", "e2e-loop.webm"],
355
+ );
356
+ const digests = new Set<string>();
357
+ for (const a of b.artifacts) {
358
+ assert.match(a.digest, HEX8);
359
+ digests.add(a.digest);
360
+ }
361
+ assert.equal(digests.size, 3); // pairwise distinct
362
+ assert.equal(b.plan.format, "svg"); // primary = first requested
363
+ assert.equal(b.plan.width, 1080);
364
+ assert.ok(b.plan.estimatedBytes > 0);
365
+ });
366
+
367
+ it("digests are stable across two independent full-chain runs", () => {
368
+ const a = exportBundle(chainProject(), opts);
369
+ const b = exportBundle(chainProject(), opts);
370
+ assert.deepStrictEqual(a.artifacts, b.artifacts);
371
+ assert.deepStrictEqual(a.plan, b.plan);
372
+ });
373
+
374
+ it("png-only request still ships the svg master; primary plan is png", () => {
375
+ const p = chainProject();
376
+ const b = exportBundle(p, { format: "png", width: 1440 });
377
+ assert.deepStrictEqual(
378
+ b.artifacts.map((a) => a.kind),
379
+ ["svg", "png"],
380
+ );
381
+ assert.equal(b.plan.format, "png");
382
+ assert.equal(b.plan.width, 1440);
383
+ assert.equal(b.plan.height, 1440);
384
+ assert.equal(b.artifacts[0].digest, buildSvgArtifact(p, { width: 1440, height: 1440 }).digest);
385
+ });
386
+
387
+ it("gif artifact rides the documented frame arithmetic", () => {
388
+ const p = chainProject();
389
+ const b = exportBundle(p, { format: "gif", fps: 24, loopMs: 375 });
390
+ assert.deepStrictEqual(
391
+ b.artifacts.map((a) => a.kind),
392
+ ["svg", "gif"],
393
+ );
394
+ assert.equal(b.plan.frames, 9); // round(375·24/1000)
395
+ assert.ok(b.artifacts[1].digest !== b.artifacts[0].digest);
396
+ });
397
+
398
+ it("bundle refuses bad entries and empty requests", () => {
399
+ const p = chainProject();
400
+ assert.equal(
401
+ codeOf(() => exportBundle(p, { format: ["svg", "mp4"] as never })),
402
+ "export-format",
403
+ );
404
+ assert.equal(
405
+ codeOf(() => exportBundle(p, { format: [] })),
406
+ "export-format",
407
+ );
408
+ assert.equal(
409
+ codeOf(() => exportBundle(p, {} as never)),
410
+ "export-format",
411
+ );
412
+ });
413
+
414
+ it("ExportError carries its taxonomy code, name and message", () => {
415
+ try {
416
+ exportPlan(chainProject(), { format: "jpeg" as never });
417
+ assert.fail("expected throw");
418
+ } catch (e) {
419
+ assert.ok(e instanceof ExportError && e instanceof Error);
420
+ assert.equal((e as ExportError).code, "export-format");
421
+ assert.equal(e.name, "ExportError");
422
+ assert.ok(e.message.length > 0);
423
+ }
424
+ });
425
+
426
+ it("toExportProject derives and sanitizes damaged or missing pieces", () => {
427
+ const d = fixture();
428
+ const full = chainProject();
429
+ assert.deepStrictEqual(full.palette, synthPalette(d));
430
+ assert.deepStrictEqual(full.blocks, rhythmScatter(d, compositionBlocks(d, compositionGrid(d))));
431
+ assert.ok(full.texture.grainOpacity <= full.texture.grainDensity + 0.2); // documented coupling
432
+ assert.equal(full.motion.loopMs, 500);
433
+ // damaged palette → re-derived from the descriptor, bit-for-bit
434
+ const healed = toExportProject("e2e-loop", { descriptor: d, palette: { anchor: 1 } as never });
435
+ assert.deepStrictEqual(healed.palette, synthPalette(d));
436
+ // everything missing → every layer still derivable
437
+ const bare = toExportProject("bare", { descriptor: d });
438
+ assert.deepStrictEqual(bare.palette, synthPalette(d));
439
+ assert.ok(bare.blocks.length >= 1);
440
+ assert.ok(Number.isFinite(bare.texture.glazeLayers) && bare.texture.glazeLayers >= 1);
441
+ assert.ok(bare.motion.loopMs > 0);
442
+ });
443
+ });