@nodaro/shared 2.24.0 → 2.27.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.24.0",
3
+ "version": "2.27.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -395,10 +395,12 @@ describe("LLM_FEATURE_DEFAULTS", () => {
395
395
  "image-critic",
396
396
  "pick-best-llm",
397
397
  "workflow-copilot",
398
+ // Scene3D previz authoring — generate-3d-scene AND edit-3d-scene share it.
399
+ "3d-scene",
398
400
  ]
399
401
 
400
- it("has entries for all 17 features", () => {
401
- expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(17)
402
+ it("has entries for all 18 features", () => {
403
+ expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(18)
402
404
  for (const feature of ALL_FEATURES) {
403
405
  expect(LLM_FEATURE_DEFAULTS).toHaveProperty(feature)
404
406
  }
@@ -223,3 +223,14 @@ describe("presetApplyClearKeys", () => {
223
223
  expect(PRESET_APPLY_CLEAR_KEYS).not.toContain("promptSuffix")
224
224
  })
225
225
  })
226
+
227
+ describe("Scene3D presets", () => {
228
+ it("excludes and clears revision history and object bindings while retaining the brief", () => {
229
+ const state = { scenePrompt: "A dolly shot", scenePlan: { revisionId: "r1" }, sceneHistory: [{ context: { prompt: "old private brief", references: [{ url: "https://example.com/old.png" }] } }], scenePendingPlan: {}, sceneJobBaseRevisionId: "r1", expectedRevisionId: "r1", lockedObjectIds: ["old-object"], selectedObjectIds: ["old-object"], referenceRoles: { source: "layout" }, referenceObjectIds: { source: "old-object" }, changeSummary: "Old edit" }
230
+ expect(extractPresetData(state)).toEqual({ scenePrompt: "A dolly shot" })
231
+ for (const key of Object.keys(state).filter((key) => key !== "scenePrompt")) {
232
+ expect(presetApplyClearKeys({ scenePrompt: "New scene" })).toContain(key)
233
+ }
234
+ expect(presetApplyClearKeys({ scenePrompt: "New scene" })).toContain("promptPrefix")
235
+ })
236
+ })
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Scene3D contract tests.
3
+ *
4
+ * The rules here are the ones a downstream consumer is entitled to assume:
5
+ * a parsed plan has no cycles, no dangling parents, no out-of-range or
6
+ * unsorted keyframes and no scene longer than the ceiling; an edit produces a
7
+ * NEW revision without touching the input; a stale revision and a locked
8
+ * object are refused rather than silently applied.
9
+ */
10
+ import { describe, it, expect } from "vitest"
11
+ import {
12
+ SCENE3D_LIMITS,
13
+ scene3DColorSchema,
14
+ SCENE3D_PLAN_TYPE,
15
+ SCENE3D_SCHEMA_VERSION,
16
+ applyScene3DEditOperations,
17
+ isScene3DPlan,
18
+ newScene3DRevisionId,
19
+ scene3DDeepEqual,
20
+ scene3DEditOperationSchema,
21
+ scene3DPlanSchema,
22
+ summarizeScene3DOperations,
23
+ type Scene3DObject,
24
+ type Scene3DPlan,
25
+ } from "../index.js"
26
+
27
+ const REV_A = "11111111-2222-4333-8444-555555555555"
28
+ const REV_B = "66666666-7777-4888-8999-aaaaaaaaaaaa"
29
+
30
+ function object(id: string, over: Partial<Scene3DObject> = {}): Scene3DObject {
31
+ return {
32
+ id,
33
+ name: id,
34
+ primitive: "box",
35
+ dimensions: [1, 1, 1],
36
+ position: [0, 0, 0],
37
+ rotation: [0, 0, 0],
38
+ scale: [1, 1, 1],
39
+ color: "#8899aa",
40
+ ...over,
41
+ }
42
+ }
43
+
44
+ function plan(over: Partial<Scene3DPlan> = {}): Scene3DPlan {
45
+ return {
46
+ planType: SCENE3D_PLAN_TYPE,
47
+ schemaVersion: SCENE3D_SCHEMA_VERSION,
48
+ revisionId: REV_A,
49
+ width: 1280,
50
+ height: 720,
51
+ fps: 24,
52
+ durationInFrames: 96,
53
+ backgroundColor: "#101014",
54
+ camera: { position: [0, 2, 6], target: [0, 0, 0], focalLengthMm: 35, sensorWidthMm: 36 },
55
+ objects: [object("ground", { primitive: "plane", dimensions: [10, 0.01, 10] })],
56
+ lighting: { ambientIntensity: 0.4, keyIntensity: 1.2, keyPosition: [4, 6, 4] },
57
+ ...over,
58
+ }
59
+ }
60
+
61
+ describe("scene3DPlanSchema — structure", () => {
62
+ it("accepts a minimal well-formed plan", () => {
63
+ expect(scene3DPlanSchema.safeParse(plan()).success).toBe(true)
64
+ expect(isScene3DPlan(plan())).toBe(true)
65
+ })
66
+
67
+ it("defaults sensorWidthMm to full frame", () => {
68
+ const camera = { position: [0, 2, 6], target: [0, 0, 0], focalLengthMm: 35 }
69
+ const parsed = scene3DPlanSchema.parse({ ...plan(), camera })
70
+ expect(parsed.camera.sensorWidthMm).toBe(SCENE3D_LIMITS.defaultSensorWidthMm)
71
+ })
72
+
73
+ it("rejects an unknown top-level field rather than silently dropping it", () => {
74
+ expect(scene3DPlanSchema.safeParse({ ...plan(), script: "rm -rf /" }).success).toBe(false)
75
+ })
76
+
77
+ it("rejects a non-finite coordinate", () => {
78
+ const broken = plan({ objects: [object("a", { position: [Number.NaN, 0, 0] })] })
79
+ expect(scene3DPlanSchema.safeParse(broken).success).toBe(false)
80
+ const infinite = plan({ objects: [object("a", { position: [Number.POSITIVE_INFINITY, 0, 0] })] })
81
+ expect(scene3DPlanSchema.safeParse(infinite).success).toBe(false)
82
+ })
83
+
84
+ it("rejects a scene longer than the duration ceiling", () => {
85
+ const tooLong = plan({ fps: 24, durationInFrames: 24 * (SCENE3D_LIMITS.maxDurationSeconds + 1) })
86
+ const result = scene3DPlanSchema.safeParse(tooLong)
87
+ expect(result.success).toBe(false)
88
+ expect(JSON.stringify(result.error?.issues)).toContain("the limit is 60s")
89
+ })
90
+
91
+ it("rejects duplicate object ids", () => {
92
+ const dup = plan({ objects: [object("a"), object("a")] })
93
+ const result = scene3DPlanSchema.safeParse(dup)
94
+ expect(result.success).toBe(false)
95
+ expect(JSON.stringify(result.error?.issues)).toContain("duplicate object id")
96
+ })
97
+
98
+ it("rejects a dangling parentId", () => {
99
+ const orphan = plan({ objects: [object("a", { parentId: "ghost" })] })
100
+ const result = scene3DPlanSchema.safeParse(orphan)
101
+ expect(result.success).toBe(false)
102
+ expect(JSON.stringify(result.error?.issues)).toContain("unknown parent")
103
+ })
104
+
105
+ it("rejects a self-parent", () => {
106
+ const self = plan({ objects: [object("a", { parentId: "a" })] })
107
+ expect(scene3DPlanSchema.safeParse(self).success).toBe(false)
108
+ })
109
+
110
+ it("rejects a two-object parent cycle", () => {
111
+ const cycle = plan({ objects: [object("a", { parentId: "b" }), object("b", { parentId: "a" })] })
112
+ const result = scene3DPlanSchema.safeParse(cycle)
113
+ expect(result.success).toBe(false)
114
+ expect(JSON.stringify(result.error?.issues)).toContain("parent cycle")
115
+ })
116
+
117
+ it("accepts a legal parent chain", () => {
118
+ const chain = plan({
119
+ objects: [object("root"), object("mid", { parentId: "root" }), object("leaf", { parentId: "mid" })],
120
+ })
121
+ expect(scene3DPlanSchema.safeParse(chain).success).toBe(true)
122
+ })
123
+
124
+ it("rejects a hierarchy deeper than the ceiling", () => {
125
+ const objects = [object("n0")]
126
+ for (let i = 1; i <= SCENE3D_LIMITS.maxHierarchyDepth + 1; i++) {
127
+ objects.push(object(`n${i}`, { parentId: `n${i - 1}` }))
128
+ }
129
+ const result = scene3DPlanSchema.safeParse(plan({ objects }))
130
+ expect(result.success).toBe(false)
131
+ expect(JSON.stringify(result.error?.issues)).toContain("deeper than")
132
+ })
133
+
134
+ it("rejects unsorted, duplicated and out-of-range keyframes", () => {
135
+ const unsorted = plan({ objects: [object("a", { keyframes: [{ frame: 10 }, { frame: 2 }] })] })
136
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(unsorted).error?.issues)).toContain("sorted")
137
+
138
+ const duplicated = plan({ objects: [object("a", { keyframes: [{ frame: 4 }, { frame: 4 }] })] })
139
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(duplicated).error?.issues)).toContain("duplicate keyframe")
140
+
141
+ const past = plan({ durationInFrames: 10, objects: [object("a", { keyframes: [{ frame: 10 }] })] })
142
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(past).error?.issues)).toContain("past the scene's last frame")
143
+ })
144
+
145
+ it("applies the same keyframe rules to the camera track", () => {
146
+ const camera = {
147
+ position: [0, 2, 6] as [number, number, number],
148
+ target: [0, 0, 0] as [number, number, number],
149
+ focalLengthMm: 35,
150
+ sensorWidthMm: 36,
151
+ keyframes: [{ frame: 30 }, { frame: 5 }],
152
+ }
153
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(plan({ camera })).error?.issues)).toContain("sorted")
154
+ })
155
+
156
+ it("validates references: http(s) only, resolved objectId, sane window", () => {
157
+ const base = plan({ objects: [object("hero")] })
158
+ const good = {
159
+ ...base,
160
+ references: [
161
+ { id: "r1", url: "https://cdn.example.com/a.png", kind: "image", role: "appearance", objectId: "hero" },
162
+ { id: "r2", url: "https://cdn.example.com/b.mp4", kind: "video", role: "motion", startSeconds: 1, endSeconds: 3 },
163
+ ],
164
+ }
165
+ expect(scene3DPlanSchema.safeParse(good).success).toBe(true)
166
+
167
+ const badScheme = { ...base, references: [{ id: "r1", url: "file:///etc/passwd", kind: "image", role: "layout" }] }
168
+ expect(scene3DPlanSchema.safeParse(badScheme).success).toBe(false)
169
+
170
+ const ghostObject = {
171
+ ...base,
172
+ references: [{ id: "r1", url: "https://x.test/a.png", kind: "image", role: "layout", objectId: "nope" }],
173
+ }
174
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(ghostObject).error?.issues)).toContain("unknown object")
175
+
176
+ const backwards = {
177
+ ...base,
178
+ references: [{ id: "r1", url: "https://x.test/a.mp4", kind: "video", role: "motion", startSeconds: 5, endSeconds: 2 }],
179
+ }
180
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(backwards).error?.issues)).toContain("before it starts")
181
+
182
+ const windowedImage = {
183
+ ...base,
184
+ references: [{ id: "r1", url: "https://x.test/a.png", kind: "image", role: "layout", startSeconds: 1 }],
185
+ }
186
+ expect(scene3DPlanSchema.safeParse(windowedImage).success).toBe(false)
187
+ })
188
+
189
+ it("rejects duplicate reference ids", () => {
190
+ const dup = {
191
+ ...plan(),
192
+ references: [
193
+ { id: "r1", url: "https://x.test/a.png", kind: "image", role: "layout" },
194
+ { id: "r1", url: "https://x.test/b.png", kind: "image", role: "layout" },
195
+ ],
196
+ }
197
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(dup).error?.issues)).toContain("duplicate reference id")
198
+ })
199
+
200
+ it("requires at least one object and caps the count", () => {
201
+ expect(scene3DPlanSchema.safeParse(plan({ objects: [] })).success).toBe(false)
202
+ const many = Array.from({ length: SCENE3D_LIMITS.maxObjects + 1 }, (_, i) => object(`o${i}`))
203
+ expect(scene3DPlanSchema.safeParse(plan({ objects: many })).success).toBe(false)
204
+ })
205
+
206
+ it("bounds the render size and fps", () => {
207
+ expect(scene3DPlanSchema.safeParse(plan({ width: 4096 })).success).toBe(false)
208
+ expect(scene3DPlanSchema.safeParse(plan({ fps: 120 })).success).toBe(false)
209
+ expect(scene3DPlanSchema.safeParse(plan({ fps: 24.5 })).success).toBe(false)
210
+ })
211
+ })
212
+
213
+ describe("newScene3DRevisionId", () => {
214
+ it("produces distinct v4 UUIDs the plan schema accepts", () => {
215
+ const a = newScene3DRevisionId()
216
+ const b = newScene3DRevisionId()
217
+ expect(a).not.toBe(b)
218
+ expect(scene3DPlanSchema.safeParse(plan({ revisionId: a })).success).toBe(true)
219
+ })
220
+ })
221
+
222
+ describe("scene3DEditOperationSchema", () => {
223
+ it("refuses to let an operation rename an object", () => {
224
+ const sneaky = { op: "set-object", objectId: "a", changes: { id: "b" } }
225
+ expect(scene3DEditOperationSchema.safeParse(sneaky).success).toBe(false)
226
+ })
227
+
228
+ it("accepts a null parentId as an explicit detach", () => {
229
+ const detach = { op: "set-object", objectId: "a", changes: { parentId: null } }
230
+ expect(scene3DEditOperationSchema.safeParse(detach).success).toBe(true)
231
+ })
232
+
233
+ it("rejects an unknown op", () => {
234
+ expect(scene3DEditOperationSchema.safeParse({ op: "eval", code: "1" }).success).toBe(false)
235
+ })
236
+ })
237
+
238
+ describe("applyScene3DEditOperations", () => {
239
+ const base = plan({ objects: [object("ground", { primitive: "plane" }), object("hero")] })
240
+
241
+ it("produces a new revision, records the parent, and never mutates the input", () => {
242
+ const snapshot = JSON.parse(JSON.stringify(base))
243
+ const result = applyScene3DEditOperations(base, [
244
+ { op: "set-object", objectId: "hero", changes: { position: [1, 0, 2] } },
245
+ ])
246
+ expect(result.ok).toBe(true)
247
+ if (!result.ok) return
248
+ expect(result.plan.revisionId).not.toBe(base.revisionId)
249
+ expect(result.plan.parentRevisionId).toBe(base.revisionId)
250
+ expect(result.plan.objects.find((o) => o.id === "hero")?.position).toEqual([1, 0, 2])
251
+ expect(base).toEqual(snapshot)
252
+ expect(result.changedObjectIds).toEqual(["hero"])
253
+ expect(result.changeSummary).toContain("hero")
254
+ })
255
+
256
+ it("refuses a stale expectedRevisionId before applying anything", () => {
257
+ const result = applyScene3DEditOperations(
258
+ base,
259
+ [{ op: "remove-object", objectId: "hero" }],
260
+ { expectedRevisionId: REV_B },
261
+ )
262
+ expect(result.ok).toBe(false)
263
+ if (result.ok) return
264
+ expect(result.code).toBe("stale_revision")
265
+ })
266
+
267
+ it("accepts a matching expectedRevisionId", () => {
268
+ const result = applyScene3DEditOperations(base, [{ op: "set-background", color: "#000000" }], {
269
+ expectedRevisionId: REV_A,
270
+ })
271
+ expect(result.ok).toBe(true)
272
+ if (!result.ok) return
273
+ expect(result.plan.backgroundColor).toBe("#000000")
274
+ })
275
+
276
+ it("reports the failing operation index", () => {
277
+ const result = applyScene3DEditOperations(base, [
278
+ { op: "set-background", color: "#111111" },
279
+ { op: "remove-object", objectId: "ghost" },
280
+ ])
281
+ expect(result.ok).toBe(false)
282
+ if (result.ok) return
283
+ expect(result.code).toBe("unknown_object")
284
+ expect(result.operationIndex).toBe(1)
285
+ })
286
+
287
+ it("rejects a duplicate add", () => {
288
+ const result = applyScene3DEditOperations(base, [{ op: "add-object", object: object("hero") }])
289
+ expect(result.ok).toBe(false)
290
+ if (result.ok) return
291
+ expect(result.code).toBe("duplicate_object")
292
+ })
293
+
294
+ it("refuses to modify a locked object", () => {
295
+ const result = applyScene3DEditOperations(
296
+ base,
297
+ [{ op: "set-object", objectId: "hero", changes: { color: "#ff0000" } }],
298
+ { lockedObjectIds: ["hero"] },
299
+ )
300
+ expect(result.ok).toBe(false)
301
+ if (result.ok) return
302
+ expect(result.code).toBe("locked_object")
303
+ expect(result.message).toContain("cannot be modified")
304
+ })
305
+
306
+ it("refuses to remove a locked object", () => {
307
+ const result = applyScene3DEditOperations(base, [{ op: "remove-object", objectId: "hero" }], {
308
+ lockedObjectIds: ["hero"],
309
+ })
310
+ expect(result.ok).toBe(false)
311
+ if (result.ok) return
312
+ expect(result.message).toContain("cannot be removed")
313
+ })
314
+
315
+ it("catches a remove-then-re-add that would smuggle a change past the lock", () => {
316
+ const result = applyScene3DEditOperations(
317
+ base,
318
+ [
319
+ { op: "remove-object", objectId: "hero" },
320
+ { op: "add-object", object: object("hero", { color: "#00ff00" }) },
321
+ ],
322
+ { lockedObjectIds: ["hero"] },
323
+ )
324
+ expect(result.ok).toBe(false)
325
+ if (result.ok) return
326
+ expect(result.code).toBe("locked_object")
327
+ })
328
+
329
+ it("allows an unrelated edit while an object is locked", () => {
330
+ const result = applyScene3DEditOperations(
331
+ base,
332
+ [{ op: "set-object", objectId: "ground", changes: { color: "#223344" } }],
333
+ { lockedObjectIds: ["hero"] },
334
+ )
335
+ expect(result.ok).toBe(true)
336
+ })
337
+
338
+ it("refuses to orphan a child by removing its parent", () => {
339
+ const nested = plan({ objects: [object("root"), object("child", { parentId: "root" })] })
340
+ const result = applyScene3DEditOperations(nested, [{ op: "remove-object", objectId: "root" }])
341
+ expect(result.ok).toBe(false)
342
+ if (result.ok) return
343
+ expect(result.code).toBe("invalid_plan")
344
+ expect(result.message).toContain("unknown parent")
345
+ })
346
+
347
+ it("refuses to remove an object a reference points at", () => {
348
+ const referenced: Scene3DPlan = {
349
+ ...plan({ objects: [object("hero"), object("prop")] }),
350
+ references: [{ id: "r1", url: "https://x.test/a.png", kind: "image", role: "appearance", objectId: "prop" }],
351
+ }
352
+ const result = applyScene3DEditOperations(referenced, [{ op: "remove-object", objectId: "prop" }])
353
+ expect(result.ok).toBe(false)
354
+ if (result.ok) return
355
+ expect(result.code).toBe("invalid_plan")
356
+ })
357
+
358
+ it("removing a parent is fine once the child is detached in the same list", () => {
359
+ const nested = plan({ objects: [object("root"), object("child", { parentId: "root" })] })
360
+ const result = applyScene3DEditOperations(nested, [
361
+ { op: "set-object", objectId: "child", changes: { parentId: null } },
362
+ { op: "remove-object", objectId: "root" },
363
+ ])
364
+ expect(result.ok).toBe(true)
365
+ if (!result.ok) return
366
+ expect(result.plan.objects).toHaveLength(1)
367
+ expect(result.plan.objects[0].parentId).toBeUndefined()
368
+ })
369
+
370
+ it("rejects an operation list that would leave the scene invalid", () => {
371
+ const result = applyScene3DEditOperations(base, [
372
+ { op: "remove-object", objectId: "hero" },
373
+ { op: "remove-object", objectId: "ground" },
374
+ ])
375
+ expect(result.ok).toBe(false)
376
+ if (result.ok) return
377
+ expect(result.code).toBe("invalid_plan")
378
+ })
379
+
380
+ it("rejects an invalid input plan without applying anything", () => {
381
+ const result = applyScene3DEditOperations({ ...base, objects: [] } as Scene3DPlan, [
382
+ { op: "set-background", color: "#000000" },
383
+ ])
384
+ expect(result.ok).toBe(false)
385
+ if (result.ok) return
386
+ expect(result.code).toBe("invalid_plan")
387
+ })
388
+
389
+ it("caps the operation list", () => {
390
+ const many = Array.from({ length: SCENE3D_LIMITS.maxOperations + 1 }, () => ({
391
+ op: "set-background" as const,
392
+ color: "#000000",
393
+ }))
394
+ const result = applyScene3DEditOperations(base, many)
395
+ expect(result.ok).toBe(false)
396
+ if (result.ok) return
397
+ expect(result.code).toBe("invalid_operations")
398
+ })
399
+
400
+ it("rejects a malformed operation without a partial apply", () => {
401
+ const result = applyScene3DEditOperations(base, [{ op: "set-object", objectId: "hero", changes: { color: "red" } }])
402
+ expect(result.ok).toBe(false)
403
+ if (result.ok) return
404
+ expect(result.code).toBe("invalid_operations")
405
+ expect(result.operationIndex).toBe(0)
406
+ })
407
+
408
+ it("honours a pinned revisionId for deterministic replay", () => {
409
+ const result = applyScene3DEditOperations(base, [{ op: "set-background", color: "#0a0a0a" }], {
410
+ revisionId: REV_B,
411
+ })
412
+ expect(result.ok).toBe(true)
413
+ if (!result.ok) return
414
+ expect(result.plan.revisionId).toBe(REV_B)
415
+ })
416
+
417
+ it("edits camera and lighting without touching objects", () => {
418
+ const result = applyScene3DEditOperations(base, [
419
+ { op: "set-camera", changes: { focalLengthMm: 85, target: [0, 1, 0] } },
420
+ { op: "set-lighting", changes: { keyIntensity: 2 } },
421
+ ])
422
+ expect(result.ok).toBe(true)
423
+ if (!result.ok) return
424
+ expect(result.plan.camera.focalLengthMm).toBe(85)
425
+ expect(result.plan.camera.position).toEqual(base.camera.position)
426
+ expect(result.plan.lighting.keyIntensity).toBe(2)
427
+ expect(result.plan.objects).toEqual(base.objects)
428
+ })
429
+ })
430
+
431
+ describe("summarizeScene3DOperations", () => {
432
+ it("names each change", () => {
433
+ const summary = summarizeScene3DOperations([
434
+ { op: "add-object", object: object("lamp", { primitive: "cone" }) },
435
+ { op: "remove-object", objectId: "ground" },
436
+ { op: "set-background", color: "#123456" },
437
+ ])
438
+ expect(summary).toContain("Added cone")
439
+ expect(summary).toContain("Removed \"ground\"")
440
+ expect(summary).toContain("#123456")
441
+ })
442
+ })
443
+
444
+ describe("scene3DDeepEqual", () => {
445
+ it("ignores key order but not values", () => {
446
+ expect(scene3DDeepEqual({ a: 1, b: [1, 2] }, { b: [1, 2], a: 1 })).toBe(true)
447
+ expect(scene3DDeepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false)
448
+ expect(scene3DDeepEqual([1, 2], [2, 1])).toBe(false)
449
+ })
450
+ })
451
+
452
+ describe("colors are OPAQUE hex only", () => {
453
+ // `new THREE.Color(hex)` understands #abc and #aabbcc and nothing else. It
454
+ // does not REFUSE a 4- or 8-digit value — it warns and falls back to WHITE,
455
+ // so an alpha the contract accepted would silently repaint an object in the
456
+ // export while the plan looked perfectly valid.
457
+ it("accepts 3- and 6-digit values", () => {
458
+ expect(scene3DColorSchema.safeParse("#abc").success).toBe(true)
459
+ expect(scene3DColorSchema.safeParse("#4f8ef7").success).toBe(true)
460
+ expect(scene3DColorSchema.safeParse("#ABCDEF").success).toBe(true)
461
+ })
462
+
463
+ it("rejects the CSS alpha spellings", () => {
464
+ expect(scene3DColorSchema.safeParse("#abcd").success).toBe(false)
465
+ expect(scene3DColorSchema.safeParse("#aabbccdd").success).toBe(false)
466
+ })
467
+
468
+ it("rejects them everywhere a color appears, not just at the top level", () => {
469
+ expect(scene3DPlanSchema.safeParse(plan({ backgroundColor: "#aabbccdd" })).success).toBe(false)
470
+ expect(
471
+ scene3DPlanSchema.safeParse(plan({ objects: [object("a", { color: "#abcd" })] })).success,
472
+ ).toBe(false)
473
+ })
474
+ })
475
+
476
+ describe("SCENE3D_LIMITS.minDurationSeconds", () => {
477
+ it("is the frozen v1 floor of one second", () => {
478
+ // Quoted by the route Zod and by the canvas path, so neither can accept a
479
+ // scene shorter than the SDK/MCP surface and the public docs promise.
480
+ expect(SCENE3D_LIMITS.minDurationSeconds).toBe(1)
481
+ })
482
+ })