@flighthq/scene-document 0.4.1-edge.37.ff47e13 → 0.4.1-next.109.0f3397e

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 (40) hide show
  1. package/README.md +2 -2
  2. package/dist/contract.d.ts +2 -1
  3. package/dist/contract.d.ts.map +1 -1
  4. package/dist/contract.js +2 -1
  5. package/dist/contract.js.map +1 -1
  6. package/dist/flightDocumentText.d.ts +5 -0
  7. package/dist/flightDocumentText.d.ts.map +1 -0
  8. package/dist/flightDocumentText.js +617 -0
  9. package/dist/flightDocumentText.js.map +1 -0
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/sceneDocumentMaterializationSelection.d.ts +13 -0
  15. package/dist/sceneDocumentMaterializationSelection.d.ts.map +1 -0
  16. package/dist/sceneDocumentMaterializationSelection.js +55 -0
  17. package/dist/sceneDocumentMaterializationSelection.js.map +1 -0
  18. package/dist/sceneDocumentRefusal.d.ts +6 -0
  19. package/dist/sceneDocumentRefusal.d.ts.map +1 -0
  20. package/dist/sceneDocumentRefusal.js +60 -0
  21. package/dist/sceneDocumentRefusal.js.map +1 -0
  22. package/dist/sceneDocumentScene2DMaterialization.d.ts +4 -5
  23. package/dist/sceneDocumentScene2DMaterialization.d.ts.map +1 -1
  24. package/dist/sceneDocumentScene2DMaterialization.js +81 -277
  25. package/dist/sceneDocumentScene2DMaterialization.js.map +1 -1
  26. package/dist/sceneDocumentScene3DMaterialization.d.ts +4 -4
  27. package/dist/sceneDocumentScene3DMaterialization.d.ts.map +1 -1
  28. package/dist/sceneDocumentScene3DMaterialization.js +79 -278
  29. package/dist/sceneDocumentScene3DMaterialization.js.map +1 -1
  30. package/dist/sceneDocumentYamlSubset.d.ts +3 -1
  31. package/dist/sceneDocumentYamlSubset.d.ts.map +1 -1
  32. package/dist/sceneDocumentYamlSubset.js.map +1 -1
  33. package/package.json +9 -9
  34. package/src/flightDocumentText.test.ts +179 -0
  35. package/src/sceneDocumentMaterializationSelection.test.ts +56 -0
  36. package/src/sceneDocumentMultiSceneMaterialization.test.ts +209 -0
  37. package/src/sceneDocumentRefusal.test.ts +143 -0
  38. package/src/sceneDocumentScene2DMaterialization.test.ts +188 -62
  39. package/src/sceneDocumentScene3DMaterialization.test.ts +329 -154
  40. package/src/sceneDocumentYamlSubset.test.ts +8 -0
@@ -2,13 +2,15 @@ import { createTransform3D } from '@flighthq/geometry/contract';
2
2
  import { createAmbientLight, createDirectionalLight } from '@flighthq/lighting/contract';
3
3
  import { getNodeChildren } from '@flighthq/node/contract';
4
4
  import { createKeyedTable, withRegistryTableEntry } from '@flighthq/registry/contract';
5
+ import { createDisplayObject } from '@flighthq/scene2d/contract';
5
6
  import { createNode3D, createScene3D } from '@flighthq/scene3d/contract';
6
7
  import type {
7
8
  FlightDocument,
8
9
  FlightDocumentFields,
9
10
  FlightDocumentNodeSchema,
11
+ FlightDocumentResourceDescriptor,
10
12
  FlightDocumentResourceLookup,
11
- FlightDocumentScene3D,
13
+ FlightDocumentScene,
12
14
  FlightDocumentScene3DMaterialization,
13
15
  FlightDocumentSchemaRegistry,
14
16
  NodeAny,
@@ -18,12 +20,13 @@ import type {
18
20
  import {
19
21
  AmbientLightKind,
20
22
  DirectionalLightKind,
23
+ DisplayObjectKind,
21
24
  FlightDocumentRefusalReason,
22
25
  Node3DKind,
23
26
  } from '@flighthq/types/contract';
24
27
  import { describe, expect, expectTypeOf, it } from 'vitest';
25
28
 
26
- import { serializeFlightDocument } from './sceneDocumentScene2DMaterialization';
29
+ import { formatFlightDocumentText } from './flightDocumentText';
27
30
  import {
28
31
  createFlightDocumentFromScene3D,
29
32
  createFlightDocumentScene3DMaterialization,
@@ -37,7 +40,6 @@ describe('createFlightDocumentFromScene3D', () => {
37
40
  const scene = createScene3D();
38
41
  const document = createFlightDocumentFromScene3D(scene, [], [], createTestSchemas());
39
42
  expect(document.kind).toBe('Scene3D');
40
- expect(document.version).toBe(1);
41
43
  expect(document.scene.children).toHaveLength(0);
42
44
  expect(document.cameras).toHaveLength(0);
43
45
  expect(document.lights).toHaveLength(0);
@@ -71,14 +73,12 @@ describe('createFlightDocumentFromScene3D', () => {
71
73
 
72
74
  describe('createFlightDocumentScene3DMaterialization', () => {
73
75
  it('materializes an empty Scene3D from a minimal model', () => {
74
- const document: FlightDocumentScene3D = {
76
+ const document = createTestDocument({
75
77
  cameras: [],
76
78
  kind: 'Scene3D',
77
79
  lights: [],
78
- resources: [],
79
80
  scene: { children: [], fields: {}, kind: Node3DKind },
80
- version: 1,
81
- };
81
+ });
82
82
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
83
83
  expect(result).not.toBeNull();
84
84
  const materialization = result as FlightDocumentScene3DMaterialization;
@@ -88,7 +88,7 @@ describe('createFlightDocumentScene3DMaterialization', () => {
88
88
  });
89
89
 
90
90
  it('materializes cameras from document camera descriptors', () => {
91
- const document: FlightDocumentScene3D = {
91
+ const document = createTestDocument({
92
92
  cameras: [
93
93
  {
94
94
  far: 500,
@@ -99,10 +99,8 @@ describe('createFlightDocumentScene3DMaterialization', () => {
99
99
  ],
100
100
  kind: 'Scene3D',
101
101
  lights: [],
102
- resources: [],
103
102
  scene: { children: [], fields: {}, kind: Node3DKind },
104
- version: 1,
105
- };
103
+ });
106
104
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
107
105
  expect(result).not.toBeNull();
108
106
  const materialization = result as FlightDocumentScene3DMaterialization;
@@ -113,18 +111,16 @@ describe('createFlightDocumentScene3DMaterialization', () => {
113
111
  });
114
112
 
115
113
  it('materializes a tree with children', () => {
116
- const document: FlightDocumentScene3D = {
114
+ const document = createTestDocument({
117
115
  cameras: [],
118
116
  kind: 'Scene3D',
119
117
  lights: [],
120
- resources: [],
121
118
  scene: {
122
119
  children: [{ children: [], fields: {}, kind: Node3DKind }],
123
120
  fields: {},
124
121
  kind: Node3DKind,
125
122
  },
126
- version: 1,
127
- };
123
+ });
128
124
  const schemas = createTestSchemas();
129
125
  const result = createFlightDocumentScene3DMaterialization(document, schemas);
130
126
  expect(result).not.toBeNull();
@@ -135,58 +131,53 @@ describe('createFlightDocumentScene3DMaterialization', () => {
135
131
  });
136
132
 
137
133
  it('returns null for a Scene2D document', () => {
138
- const document = {
134
+ const document = createTestDocument({
139
135
  backgroundColor: null,
140
136
  kind: 'Scene2D',
141
- resources: [],
142
137
  scene: { children: [], fields: {}, kind: 'DisplayObject' },
143
- version: 1,
144
- } as FlightDocument;
138
+ });
145
139
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
146
140
  expect(result).toBeNull();
147
141
  });
148
142
 
149
143
  it('returns null for an unsupported version', () => {
150
144
  const document = {
151
- cameras: [],
152
- kind: 'Scene3D',
153
- lights: [],
154
- resources: [],
155
- scene: { children: [], fields: {}, kind: Node3DKind },
145
+ ...createTestDocument({
146
+ cameras: [],
147
+ kind: 'Scene3D',
148
+ lights: [],
149
+ scene: { children: [], fields: {}, kind: Node3DKind },
150
+ }),
156
151
  version: 99,
157
- } as unknown as FlightDocumentScene3D;
152
+ } as unknown as FlightDocument;
158
153
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
159
154
  expect(result).toBeNull();
160
155
  });
161
156
 
162
157
  it('returns null for duplicate ambient lights', () => {
163
- const document: FlightDocumentScene3D = {
158
+ const document = createTestDocument({
164
159
  cameras: [],
165
160
  kind: 'Scene3D',
166
161
  lights: [
167
162
  { descriptor: createAmbientLight(), transform: createTransform3D() },
168
163
  { descriptor: createAmbientLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
169
164
  ],
170
- resources: [],
171
165
  scene: { children: [], fields: {}, kind: Node3DKind },
172
- version: 1,
173
- };
166
+ });
174
167
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
175
168
  expect(result).toBeNull();
176
169
  });
177
170
 
178
171
  it('returns null for duplicate directional lights', () => {
179
- const document: FlightDocumentScene3D = {
172
+ const document = createTestDocument({
180
173
  cameras: [],
181
174
  kind: 'Scene3D',
182
175
  lights: [
183
176
  { descriptor: createDirectionalLight(), transform: createTransform3D() },
184
177
  { descriptor: createDirectionalLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
185
178
  ],
186
- resources: [],
187
179
  scene: { children: [], fields: {}, kind: Node3DKind },
188
- version: 1,
189
- };
180
+ });
190
181
  const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
191
182
  expect(result).toBeNull();
192
183
  });
@@ -194,7 +185,14 @@ describe('createFlightDocumentScene3DMaterialization', () => {
194
185
 
195
186
  describe('createFlightDocumentScene3DMaterializationFromText', () => {
196
187
  it('materializes a Scene3D from YAML text', () => {
197
- const yaml = ['flight: 1', 'kind: Scene3D', 'scene:', ' kind: Node3D'].join('\n');
188
+ const yaml = [
189
+ 'flight: 1',
190
+ 'defaultScene: 0',
191
+ 'scenes:',
192
+ ' - kind: Scene3D',
193
+ ' scene:',
194
+ ' kind: Node3D',
195
+ ].join('\n');
198
196
  const schemas = createTestSchemas();
199
197
  const result = createFlightDocumentScene3DMaterializationFromText(yaml, schemas);
200
198
  expect(result).not.toBeNull();
@@ -203,87 +201,43 @@ describe('createFlightDocumentScene3DMaterializationFromText', () => {
203
201
  });
204
202
 
205
203
  it('returns null for a Scene2D YAML document', () => {
206
- const yaml = ['flight: 1', 'kind: Scene2D', 'scene:', ' kind: DisplayObject'].join('\n');
204
+ const yaml = [
205
+ 'flight: 1',
206
+ 'defaultScene: 0',
207
+ 'scenes:',
208
+ ' - kind: Scene2D',
209
+ ' scene:',
210
+ ' kind: DisplayObject',
211
+ ].join('\n');
207
212
  const result = createFlightDocumentScene3DMaterializationFromText(yaml, createTestSchemas());
208
213
  expect(result).toBeNull();
209
214
  });
210
215
  });
211
216
 
212
- describe('duplicate-light explain parity', () => {
213
- // The table is annotated so each fixture is checked AGAINST THE SCHEMA rather than inferred from its own
214
- // literal. Without this, `version: 1` widens to `number` and the row stops being a `FlightDocument` — and
215
- // the two obvious local repairs both make the fixture worse: `1 as const` and a cast silence the widening
216
- // while also silencing every other way a row could stop matching the document type, which is the only
217
- // thing this parity test has to hold the fixtures to.
218
- it.each<{ document: Readonly<FlightDocument>; expectedReason: FlightDocumentRefusalReason; label: string }>([
219
- {
220
- label: 'duplicate ambient',
221
- document: {
222
- cameras: [],
223
- kind: 'Scene3D',
224
- lights: [
225
- { descriptor: createAmbientLight(), transform: createTransform3D() },
226
- { descriptor: createAmbientLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
227
- ],
228
- resources: [],
229
- scene: { children: [], fields: {}, kind: Node3DKind },
230
- version: 1,
231
- },
232
- expectedReason: FlightDocumentRefusalReason.DuplicateAmbientLight,
233
- },
234
- {
235
- label: 'duplicate directional',
236
- document: {
237
- cameras: [],
238
- kind: 'Scene3D',
239
- lights: [
240
- { descriptor: createDirectionalLight(), transform: createTransform3D() },
241
- { descriptor: createDirectionalLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
242
- ],
243
- resources: [],
244
- scene: { children: [], fields: {}, kind: Node3DKind },
245
- version: 1,
246
- },
247
- expectedReason: FlightDocumentRefusalReason.DuplicateDirectionalLight,
248
- },
249
- ])('model and text explain agree on $label', ({ document, expectedReason }) => {
250
- const modelResult = explainFlightDocumentScene3DRefusal(document);
251
- const text = serializeFlightDocument(document);
252
- const textResult = explainFlightDocumentScene3DRefusalFromText(text);
253
- expect(modelResult).not.toBeNull();
254
- expect(textResult).not.toBeNull();
255
- expect(textResult!.reason).toBe(modelResult!.reason);
256
- expect(textResult!.path).toBe(modelResult!.path);
257
- expect(modelResult!.reason).toBe(expectedReason);
258
- expect(modelResult!.path).toBe('lights');
259
- });
260
- });
261
-
262
217
  describe('explainFlightDocumentScene3DRefusal', () => {
263
218
  it('explains a dimension mismatch', () => {
264
- const document = {
219
+ const document = createTestDocument({
265
220
  backgroundColor: null,
266
221
  kind: 'Scene2D',
267
- resources: [],
268
222
  scene: { children: [], fields: {}, kind: 'DisplayObject' },
269
- version: 1,
270
- } as FlightDocument;
271
- const explanation = explainFlightDocumentScene3DRefusal(document);
223
+ });
224
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
272
225
  expect(explanation).not.toBeNull();
273
226
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.StructureInvalid);
274
- expect(explanation!.path).toBe('kind');
227
+ expect(explanation!.path).toBe('scenes[0].kind');
275
228
  });
276
229
 
277
230
  it('explains an unsupported version', () => {
278
231
  const document = {
279
- cameras: [],
280
- kind: 'Scene3D',
281
- lights: [],
282
- resources: [],
283
- scene: { children: [], fields: {}, kind: Node3DKind },
232
+ ...createTestDocument({
233
+ cameras: [],
234
+ kind: 'Scene3D',
235
+ lights: [],
236
+ scene: { children: [], fields: {}, kind: Node3DKind },
237
+ }),
284
238
  version: 99,
285
- } as unknown as FlightDocumentScene3D;
286
- const explanation = explainFlightDocumentScene3DRefusal(document);
239
+ } as unknown as FlightDocument;
240
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
287
241
  expect(explanation).not.toBeNull();
288
242
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.VersionUnsupported);
289
243
  expect(explanation!.path).toBe('version');
@@ -291,51 +245,45 @@ describe('explainFlightDocumentScene3DRefusal', () => {
291
245
  });
292
246
 
293
247
  it('explains a duplicate ambient light', () => {
294
- const document: FlightDocumentScene3D = {
248
+ const document = createTestDocument({
295
249
  cameras: [],
296
250
  kind: 'Scene3D',
297
251
  lights: [
298
252
  { descriptor: createAmbientLight(), transform: createTransform3D() },
299
253
  { descriptor: createAmbientLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
300
254
  ],
301
- resources: [],
302
255
  scene: { children: [], fields: {}, kind: Node3DKind },
303
- version: 1,
304
- };
305
- const explanation = explainFlightDocumentScene3DRefusal(document);
256
+ });
257
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
306
258
  expect(explanation).not.toBeNull();
307
259
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.DuplicateAmbientLight);
308
- expect(explanation!.path).toBe('lights');
260
+ expect(explanation!.path).toBe('scenes[0].lights');
309
261
  });
310
262
 
311
263
  it('explains a duplicate directional light', () => {
312
- const document: FlightDocumentScene3D = {
264
+ const document = createTestDocument({
313
265
  cameras: [],
314
266
  kind: 'Scene3D',
315
267
  lights: [
316
268
  { descriptor: createDirectionalLight(), transform: createTransform3D() },
317
269
  { descriptor: createDirectionalLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
318
270
  ],
319
- resources: [],
320
271
  scene: { children: [], fields: {}, kind: Node3DKind },
321
- version: 1,
322
- };
323
- const explanation = explainFlightDocumentScene3DRefusal(document);
272
+ });
273
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
324
274
  expect(explanation).not.toBeNull();
325
275
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.DuplicateDirectionalLight);
326
- expect(explanation!.path).toBe('lights');
276
+ expect(explanation!.path).toBe('scenes[0].lights');
327
277
  });
328
278
 
329
279
  it('returns null when the document is valid', () => {
330
- const document: FlightDocumentScene3D = {
280
+ const document = createTestDocument({
331
281
  cameras: [],
332
282
  kind: 'Scene3D',
333
283
  lights: [],
334
- resources: [],
335
284
  scene: { children: [], fields: {}, kind: Node3DKind },
336
- version: 1,
337
- };
338
- const explanation = explainFlightDocumentScene3DRefusal(document);
285
+ });
286
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
339
287
  expect(explanation).toBeNull();
340
288
  });
341
289
  });
@@ -343,15 +291,15 @@ describe('explainFlightDocumentScene3DRefusal', () => {
343
291
  describe('explainFlightDocumentScene3DRefusalFromText', () => {
344
292
  it('explains an anchor refusal with parser position', () => {
345
293
  const yaml = 'flight: 1\nkind: Scene3D\nanchor: &ref value\n';
346
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
294
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
347
295
  expect(explanation).not.toBeNull();
348
296
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.AnchorUnsupported);
349
297
  expect(explanation!.line).toBeGreaterThan(0);
350
298
  });
351
299
 
352
300
  it('explains a version mismatch in valid YAML', () => {
353
- const yaml = 'flight: 99\nkind: Scene3D\nscene:\n kind: Node3D\n';
354
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
301
+ const yaml = 'flight: 99\ndefaultScene: 0\nscenes:\n - kind: Scene3D\n scene:\n kind: Node3D\n';
302
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
355
303
  expect(explanation).not.toBeNull();
356
304
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.VersionUnsupported);
357
305
  expect(explanation!.path).toBe('version');
@@ -359,54 +307,251 @@ describe('explainFlightDocumentScene3DRefusalFromText', () => {
359
307
  });
360
308
 
361
309
  it('explains a dimension mismatch in valid YAML', () => {
362
- const yaml = 'flight: 1\nkind: Scene2D\nscene:\n kind: DisplayObject\n';
363
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
310
+ const yaml = 'flight: 1\ndefaultScene: 0\nscenes:\n - kind: Scene2D\n scene:\n kind: DisplayObject\n';
311
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
364
312
  expect(explanation).not.toBeNull();
365
313
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.StructureInvalid);
366
- expect(explanation!.path).toBe('kind');
314
+ expect(explanation!.path).toBe('scenes[0].kind');
367
315
  });
368
316
 
369
317
  it('explains a duplicate ambient light from text', () => {
370
- const yaml = [
371
- 'flight: 1',
372
- 'kind: Scene3D',
373
- 'scene:',
374
- ' kind: Node3D',
375
- 'lights:',
376
- ' - descriptor:',
377
- ' kind: AmbientLight',
378
- ' - descriptor:',
379
- ' kind: AmbientLight',
380
- ].join('\n');
381
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
318
+ const yaml = formatFlightDocumentText(
319
+ createTestDocument({
320
+ cameras: [],
321
+ kind: 'Scene3D',
322
+ lights: [
323
+ { descriptor: createAmbientLight(), transform: createTransform3D() },
324
+ { descriptor: createAmbientLight(), transform: createTransform3D() },
325
+ ],
326
+ scene: { children: [], fields: {}, kind: Node3DKind },
327
+ }),
328
+ );
329
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
382
330
  expect(explanation).not.toBeNull();
383
331
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.DuplicateAmbientLight);
384
- expect(explanation!.path).toBe('lights');
332
+ expect(explanation!.path).toBe('scenes[0].lights');
385
333
  });
386
334
 
387
335
  it('explains a duplicate directional light from text', () => {
388
- const yaml = [
389
- 'flight: 1',
390
- 'kind: Scene3D',
391
- 'scene:',
392
- ' kind: Node3D',
393
- 'lights:',
394
- ' - descriptor:',
395
- ' kind: DirectionalLight',
396
- ' - descriptor:',
397
- ' kind: DirectionalLight',
398
- ].join('\n');
399
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
336
+ const yaml = formatFlightDocumentText(
337
+ createTestDocument({
338
+ cameras: [],
339
+ kind: 'Scene3D',
340
+ lights: [
341
+ { descriptor: createDirectionalLight(), transform: createTransform3D() },
342
+ { descriptor: createDirectionalLight(), transform: createTransform3D() },
343
+ ],
344
+ scene: { children: [], fields: {}, kind: Node3DKind },
345
+ }),
346
+ );
347
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
400
348
  expect(explanation).not.toBeNull();
401
349
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.DuplicateDirectionalLight);
402
- expect(explanation!.path).toBe('lights');
350
+ expect(explanation!.path).toBe('scenes[0].lights');
403
351
  });
404
352
 
405
353
  it('returns null for valid Scene3D text', () => {
406
- const yaml = 'flight: 1\nkind: Scene3D\nscene:\n kind: Node3D\n';
407
- const explanation = explainFlightDocumentScene3DRefusalFromText(yaml);
354
+ const yaml = 'flight: 1\ndefaultScene: 0\nscenes:\n - kind: Scene3D\n scene:\n kind: Node3D\n';
355
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
356
+ expect(explanation).toBeNull();
357
+ });
358
+ });
359
+
360
+ describe('model-to-text explain parity', () => {
361
+ // Keep the table annotated so every fixture is checked against the container schema rather than inferred
362
+ // only from its own literal.
363
+ it.each<{
364
+ document: Readonly<FlightDocument>;
365
+ expectedPath: string;
366
+ expectedReason: FlightDocumentRefusalReason;
367
+ label: string;
368
+ }>([
369
+ {
370
+ label: 'duplicate ambient (scene 0)',
371
+ document: createTestDocument({
372
+ cameras: [],
373
+ kind: 'Scene3D',
374
+ lights: [
375
+ { descriptor: createAmbientLight(), transform: createTransform3D() },
376
+ { descriptor: createAmbientLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
377
+ ],
378
+ scene: { children: [], fields: {}, kind: Node3DKind },
379
+ }),
380
+ expectedReason: FlightDocumentRefusalReason.DuplicateAmbientLight,
381
+ expectedPath: 'scenes[0].lights',
382
+ },
383
+ {
384
+ label: 'duplicate directional (scene 0)',
385
+ document: createTestDocument({
386
+ cameras: [],
387
+ kind: 'Scene3D',
388
+ lights: [
389
+ { descriptor: createDirectionalLight(), transform: createTransform3D() },
390
+ { descriptor: createDirectionalLight({ color: 0xccccccff, intensity: 0.5 }), transform: createTransform3D() },
391
+ ],
392
+ scene: { children: [], fields: {}, kind: Node3DKind },
393
+ }),
394
+ expectedReason: FlightDocumentRefusalReason.DuplicateDirectionalLight,
395
+ expectedPath: 'scenes[0].lights',
396
+ },
397
+ {
398
+ label: 'wrong dimension (scene 0)',
399
+ document: createTestDocument({
400
+ backgroundColor: null,
401
+ kind: 'Scene2D',
402
+ scene: { children: [], fields: {}, kind: 'DisplayObject' },
403
+ }),
404
+ expectedReason: FlightDocumentRefusalReason.StructureInvalid,
405
+ expectedPath: 'scenes[0].kind',
406
+ },
407
+ {
408
+ label: 'unregistered node kind (scene 0)',
409
+ document: createTestDocument({
410
+ cameras: [],
411
+ kind: 'Scene3D',
412
+ lights: [],
413
+ scene: { children: [{ children: [], fields: {}, kind: 'acme.Unknown' }], fields: {}, kind: Node3DKind },
414
+ }),
415
+ expectedReason: FlightDocumentRefusalReason.NodeKindUnregistered,
416
+ expectedPath: 'scenes[0].scene.children[0]',
417
+ },
418
+ ])('model and text explain agree on $label', ({ document, expectedPath, expectedReason }) => {
419
+ const modelResult = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
420
+ const text = formatFlightDocumentText(document);
421
+ const textResult = explainFlightDocumentScene3DRefusalFromText(text, createTestSchemas());
422
+ expect(modelResult).not.toBeNull();
423
+ expect(textResult).not.toBeNull();
424
+ expect(textResult!.reason).toBe(modelResult!.reason);
425
+ expect(textResult!.path).toBe(modelResult!.path);
426
+ expect(modelResult!.reason).toBe(expectedReason);
427
+ expect(modelResult!.path).toBe(expectedPath);
428
+ });
429
+ });
430
+
431
+ describe('NodeKindUnregistered', () => {
432
+ it('materializer returns null for nested unregistered kind two levels deep', () => {
433
+ const document = createTestDocument({
434
+ cameras: [],
435
+ kind: 'Scene3D',
436
+ lights: [],
437
+ scene: {
438
+ children: [
439
+ {
440
+ children: [{ children: [], fields: {}, kind: 'acme.Unknown' }],
441
+ fields: {},
442
+ kind: Node3DKind,
443
+ },
444
+ ],
445
+ fields: {},
446
+ kind: Node3DKind,
447
+ },
448
+ });
449
+ const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
450
+ expect(result).toBeNull();
451
+ });
452
+
453
+ it('explain names the unregistered kind with qualified scene path', () => {
454
+ const document = createTestDocument({
455
+ cameras: [],
456
+ kind: 'Scene3D',
457
+ lights: [],
458
+ scene: {
459
+ children: [
460
+ {
461
+ children: [{ children: [], fields: {}, kind: 'acme.Unknown' }],
462
+ fields: {},
463
+ kind: Node3DKind,
464
+ },
465
+ ],
466
+ fields: {},
467
+ kind: Node3DKind,
468
+ },
469
+ });
470
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
471
+ expect(explanation).not.toBeNull();
472
+ expect(explanation!.reason).toBe(FlightDocumentRefusalReason.NodeKindUnregistered);
473
+ expect(explanation!.kind).toBe('acme.Unknown');
474
+ expect(explanation!.path).toBe('scenes[0].scene.children[0].children[0]');
475
+ });
476
+
477
+ it('registration mutation control: same tree passes when kind is registered', () => {
478
+ const document = createTestDocument({
479
+ cameras: [],
480
+ kind: 'Scene3D',
481
+ lights: [],
482
+ scene: {
483
+ children: [
484
+ {
485
+ children: [{ children: [], fields: {}, kind: Node3DKind }],
486
+ fields: {},
487
+ kind: Node3DKind,
488
+ },
489
+ ],
490
+ fields: {},
491
+ kind: Node3DKind,
492
+ },
493
+ });
494
+ const result = createFlightDocumentScene3DMaterialization(document, createTestSchemas());
495
+ expect(result).not.toBeNull();
496
+ const explanation = explainFlightDocumentScene3DRefusal(document, createTestSchemas());
408
497
  expect(explanation).toBeNull();
409
498
  });
499
+
500
+ it('text explain reports unregistered kind from YAML', () => {
501
+ const yaml = formatFlightDocumentText(
502
+ createTestDocument({
503
+ cameras: [],
504
+ kind: 'Scene3D',
505
+ lights: [],
506
+ scene: {
507
+ children: [{ children: [], fields: {}, kind: 'acme.Unknown' }],
508
+ fields: {},
509
+ kind: Node3DKind,
510
+ },
511
+ }),
512
+ );
513
+ const explanation = explainFlightDocumentScene3DRefusalFromText(yaml, createTestSchemas());
514
+ expect(explanation).not.toBeNull();
515
+ expect(explanation!.reason).toBe(FlightDocumentRefusalReason.NodeKindUnregistered);
516
+ expect(explanation!.kind).toBe('acme.Unknown');
517
+ expect(explanation!.path).toBe('scenes[0].scene.children[0]');
518
+ });
519
+ });
520
+
521
+ // ★ ROOT-NODE FIDELITY, 3D half. Same defect as 2D: the reader built a fresh container and materialized
522
+ // only the authored root's children, so the root's own kind and fields were dropped while the writer
523
+ // captured them.
524
+ describe('Scene3D root fidelity', () => {
525
+ it('preserves the authored root kind', () => {
526
+ const schemas = createTestSchemas();
527
+ const document = rootDocument3D(Node3DKind, {}, schemas);
528
+ const materialization = createFlightDocumentScene3DMaterialization(document, schemas);
529
+ expect(materialization).not.toBeNull();
530
+ expect(materialization!.scene.root.kind).toBe(Node3DKind);
531
+ });
532
+
533
+ it('preserves fields authored on the root', () => {
534
+ const schemas = createTestSchemas();
535
+ const document = rootDocument3D(Node3DKind, { name: 'authored-root' }, schemas);
536
+ const materialization = createFlightDocumentScene3DMaterialization(document, schemas);
537
+ expect(materialization).not.toBeNull();
538
+ expect(materialization!.scene.root.name).toBe('authored-root');
539
+ });
540
+
541
+ it('refuses a registered root whose kind belongs to the other dimension', () => {
542
+ const schemas = createTestSchemas();
543
+ schemas.nodeSchemas = withRegistryTableEntry(schemas.nodeSchemas, DisplayObjectKind, {
544
+ createNode: () => createDisplayObject() as unknown as NodeAny,
545
+ fields: [],
546
+ kind: DisplayObjectKind,
547
+ writeNodeFields: () => true,
548
+ });
549
+ const document = rootDocument3D(DisplayObjectKind, {}, schemas);
550
+ expect(createFlightDocumentScene3DMaterialization(document, schemas)).toBeNull();
551
+ expect(explainFlightDocumentScene3DRefusal(document, schemas)).toMatchObject({
552
+ reason: FlightDocumentRefusalReason.RootKindMismatch,
553
+ });
554
+ });
410
555
  });
411
556
 
412
557
  describe('Scene3DDocumentCamera', () => {
@@ -417,11 +562,12 @@ describe('Scene3DDocumentCamera', () => {
417
562
  });
418
563
  });
419
564
 
420
- describe('Scene3DDocumentLight', () => {
421
- it('has no direction field', () => {
422
- expectTypeOf<keyof Scene3DDocumentLight>().toEqualTypeOf<'descriptor' | 'name' | 'node' | 'transform'>();
423
- });
424
- });
565
+ function createTestDocument(
566
+ scene: FlightDocumentScene,
567
+ resources: FlightDocumentResourceDescriptor[] = [],
568
+ ): FlightDocument {
569
+ return { defaultScene: 0, resources, scenes: [scene], version: 1 };
570
+ }
425
571
 
426
572
  function createTestSchemas(): FlightDocumentSchemaRegistry {
427
573
  const node3DSchema: FlightDocumentNodeSchema = {
@@ -444,3 +590,32 @@ function createTestSchemas(): FlightDocumentSchemaRegistry {
444
590
  shapeCommandSchemas: createKeyedTable('flight-document.shape-command', 'none'),
445
591
  };
446
592
  }
593
+
594
+ describe('Scene3DDocumentLight', () => {
595
+ it('has no direction field', () => {
596
+ expectTypeOf<keyof Scene3DDocumentLight>().toEqualTypeOf<'descriptor' | 'name' | 'node' | 'transform'>();
597
+ });
598
+ });
599
+
600
+ function rootDocument3D(
601
+ kind: string,
602
+ fields: Record<string, unknown>,
603
+ schemas: FlightDocumentSchemaRegistry,
604
+ ): FlightDocument {
605
+ schemas.nodeSchemas = withRegistryTableEntry(schemas.nodeSchemas, Node3DKind, {
606
+ createNode: (nodeFields) => {
607
+ const node = createNode3D();
608
+ if (typeof nodeFields['name'] === 'string') node.name = nodeFields['name'];
609
+ return node as unknown as NodeAny;
610
+ },
611
+ fields: [],
612
+ kind: Node3DKind,
613
+ writeNodeFields: () => true,
614
+ });
615
+ return {
616
+ defaultScene: 0,
617
+ resources: [],
618
+ scenes: [{ cameras: [], kind: 'Scene3D', lights: [], scene: { children: [], fields, kind } }],
619
+ version: 1,
620
+ } as unknown as FlightDocument;
621
+ }