@flighthq/scene-document 0.4.1-next.37.ff47e13 → 0.4.1-next.515.b0834fe

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 +145 -291
  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 +10 -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 +485 -159
  40. package/src/sceneDocumentYamlSubset.test.ts +8 -0
@@ -0,0 +1,143 @@
1
+ import { createKeyedTable, withRegistryTableEntry } from '@flighthq/registry/contract';
2
+ import type {
3
+ FlightDocumentFields,
4
+ FlightDocumentNode,
5
+ FlightDocumentNodeSchema,
6
+ FlightDocumentResourceLookup,
7
+ FlightDocumentSchemaRegistry,
8
+ NodeAny,
9
+ } from '@flighthq/types/contract';
10
+ import { FlightDocumentRefusalReason } from '@flighthq/types/contract';
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import {
14
+ checkUnregisteredNodeKinds,
15
+ checkUnregisteredNodeKindsFromRaw,
16
+ createDocumentRefusal,
17
+ createSceneRefusal,
18
+ } from './sceneDocumentRefusal';
19
+
20
+ describe('checkUnregisteredNodeKinds', () => {
21
+ it('returns null when all kinds are registered', () => {
22
+ const schemas = createTestSchemas('TestKind');
23
+ const node: FlightDocumentNode = { children: [], fields: {}, kind: 'TestKind' };
24
+ expect(checkUnregisteredNodeKinds(node, schemas, 0, 'scene')).toBeNull();
25
+ });
26
+
27
+ it('returns refusal naming the unregistered kind', () => {
28
+ const schemas = createTestSchemas('TestKind');
29
+ const node: FlightDocumentNode = { children: [], fields: {}, kind: 'acme.Missing' };
30
+ const refusal = checkUnregisteredNodeKinds(node, schemas, 0, 'scene');
31
+ expect(refusal).not.toBeNull();
32
+ expect(refusal!.reason).toBe(FlightDocumentRefusalReason.NodeKindUnregistered);
33
+ expect(refusal!.kind).toBe('acme.Missing');
34
+ expect(refusal!.path).toBe('scenes[0].scene');
35
+ });
36
+
37
+ it('walks children and qualifies nested path', () => {
38
+ const schemas = createTestSchemas('TestKind');
39
+ const node: FlightDocumentNode = {
40
+ children: [
41
+ {
42
+ children: [{ children: [], fields: {}, kind: 'acme.Deep' }],
43
+ fields: {},
44
+ kind: 'TestKind',
45
+ },
46
+ ],
47
+ fields: {},
48
+ kind: 'TestKind',
49
+ };
50
+ const refusal = checkUnregisteredNodeKinds(node, schemas, 0, 'scene');
51
+ expect(refusal).not.toBeNull();
52
+ expect(refusal!.path).toBe('scenes[0].scene.children[0].children[0]');
53
+ expect(refusal!.kind).toBe('acme.Deep');
54
+ });
55
+ });
56
+
57
+ describe('checkUnregisteredNodeKindsFromRaw', () => {
58
+ it('returns null for non-object value', () => {
59
+ const schemas = createTestSchemas('TestKind');
60
+ expect(checkUnregisteredNodeKindsFromRaw(null, schemas, 0, 'scene')).toBeNull();
61
+ expect(checkUnregisteredNodeKindsFromRaw(42, schemas, 0, 'scene')).toBeNull();
62
+ });
63
+
64
+ it('returns refusal for unregistered kind in raw object', () => {
65
+ const schemas = createTestSchemas('TestKind');
66
+ const raw = { children: [], kind: 'acme.Unknown' };
67
+ const refusal = checkUnregisteredNodeKindsFromRaw(raw, schemas, 0, 'scene');
68
+ expect(refusal).not.toBeNull();
69
+ expect(refusal!.reason).toBe(FlightDocumentRefusalReason.NodeKindUnregistered);
70
+ expect(refusal!.kind).toBe('acme.Unknown');
71
+ });
72
+
73
+ it('walks raw children recursively', () => {
74
+ const schemas = createTestSchemas('TestKind');
75
+ const raw = {
76
+ children: [{ children: [], kind: 'acme.Nested' }],
77
+ kind: 'TestKind',
78
+ };
79
+ const refusal = checkUnregisteredNodeKindsFromRaw(raw, schemas, 0, 'scene');
80
+ expect(refusal).not.toBeNull();
81
+ expect(refusal!.path).toBe('scenes[0].scene.children[0]');
82
+ expect(refusal!.kind).toBe('acme.Nested');
83
+ });
84
+ });
85
+
86
+ describe('createDocumentRefusal', () => {
87
+ it('produces a refusal with the given path', () => {
88
+ const refusal = createDocumentRefusal(FlightDocumentRefusalReason.VersionUnsupported, 'version');
89
+ expect(refusal.reason).toBe(FlightDocumentRefusalReason.VersionUnsupported);
90
+ expect(refusal.path).toBe('version');
91
+ });
92
+
93
+ it('nulls all optional fields', () => {
94
+ const refusal = createDocumentRefusal(FlightDocumentRefusalReason.StructureInvalid, '');
95
+ expect(refusal.actual).toBeNull();
96
+ expect(refusal.column).toBeNull();
97
+ expect(refusal.kind).toBeNull();
98
+ expect(refusal.limit).toBeNull();
99
+ expect(refusal.line).toBeNull();
100
+ expect(refusal.offset).toBeNull();
101
+ expect(refusal.resourceKey).toBeNull();
102
+ expect(refusal.version).toBeNull();
103
+ });
104
+ });
105
+
106
+ describe('createSceneRefusal', () => {
107
+ it('qualifies path with scene index', () => {
108
+ const refusal = createSceneRefusal(FlightDocumentRefusalReason.DuplicateAmbientLight, 0, 'lights');
109
+ expect(refusal.path).toBe('scenes[0].lights');
110
+ });
111
+
112
+ it('qualifies with non-zero scene index', () => {
113
+ const refusal = createSceneRefusal(FlightDocumentRefusalReason.DuplicateDirectionalLight, 2, 'lights');
114
+ expect(refusal.path).toBe('scenes[2].lights');
115
+ });
116
+
117
+ it('omits trailing dot for empty inner path', () => {
118
+ const refusal = createSceneRefusal(FlightDocumentRefusalReason.StructureInvalid, 1, '');
119
+ expect(refusal.path).toBe('scenes[1]');
120
+ });
121
+
122
+ it('carries the reason through', () => {
123
+ const refusal = createSceneRefusal(FlightDocumentRefusalReason.StructureInvalid, 0, 'kind');
124
+ expect(refusal.reason).toBe(FlightDocumentRefusalReason.StructureInvalid);
125
+ expect(refusal.path).toBe('scenes[0].kind');
126
+ });
127
+ });
128
+
129
+ function createTestSchemas(registeredKind: string): FlightDocumentSchemaRegistry {
130
+ const schema: FlightDocumentNodeSchema = {
131
+ createNode: (_fields: Readonly<FlightDocumentFields>, _resources: FlightDocumentResourceLookup) => null,
132
+ fields: [],
133
+ kind: registeredKind,
134
+ writeNodeFields: (_out: FlightDocumentFields, _source: Readonly<NodeAny>) => true,
135
+ };
136
+ let nodeSchemas = createKeyedTable<FlightDocumentNodeSchema>('flight-document.node', 'none');
137
+ nodeSchemas = withRegistryTableEntry(nodeSchemas, registeredKind, schema);
138
+ return {
139
+ nodeSchemas,
140
+ resourceSchemas: createKeyedTable('flight-document.resource', 'none'),
141
+ shapeCommandSchemas: createKeyedTable('flight-document.shape-command', 'none'),
142
+ };
143
+ }
@@ -1,13 +1,15 @@
1
1
  import { addNodeChild, getNodeChildren } from '@flighthq/node/contract';
2
2
  import { createKeyedTable, withRegistryTableEntry } from '@flighthq/registry/contract';
3
3
  import { createDisplayObject, createScene2D, createSprite } from '@flighthq/scene2d/contract';
4
- import { DisplayObjectKind, FlightDocumentRefusalReason, SpriteKind } from '@flighthq/types/contract';
4
+ import { createNode3D } from '@flighthq/scene3d/contract';
5
+ import { DisplayObjectKind, FlightDocumentRefusalReason, Node3DKind, SpriteKind } from '@flighthq/types/contract';
5
6
  import type {
6
7
  FlightDocument,
7
8
  FlightDocumentFields,
8
9
  FlightDocumentNodeSchema,
10
+ FlightDocumentResourceDescriptor,
9
11
  FlightDocumentResourceLookup,
10
- FlightDocumentScene2D,
12
+ FlightDocumentScene,
11
13
  FlightDocumentScene2DMaterialization,
12
14
  FlightDocumentSchemaRegistry,
13
15
  Node2D,
@@ -15,13 +17,13 @@ import type {
15
17
  } from '@flighthq/types/contract';
16
18
  import { describe, expect, it } from 'vitest';
17
19
 
20
+ import { formatFlightDocumentText } from './flightDocumentText';
18
21
  import {
19
22
  createFlightDocumentFromScene2D,
20
23
  createFlightDocumentScene2DMaterialization,
21
24
  createFlightDocumentScene2DMaterializationFromText,
22
25
  explainFlightDocumentRefusal,
23
26
  explainFlightDocumentRefusalFromText,
24
- serializeFlightDocument,
25
27
  } from './sceneDocumentScene2DMaterialization';
26
28
 
27
29
  describe('createFlightDocumentFromScene2D', () => {
@@ -29,7 +31,6 @@ describe('createFlightDocumentFromScene2D', () => {
29
31
  const scene = createScene2D();
30
32
  const document = createFlightDocumentFromScene2D(scene, createTestSchemas());
31
33
  expect(document.kind).toBe('Scene2D');
32
- expect(document.version).toBe(1);
33
34
  expect(document.scene.children).toHaveLength(0);
34
35
  });
35
36
 
@@ -81,13 +82,11 @@ describe('createFlightDocumentFromScene2D', () => {
81
82
 
82
83
  describe('createFlightDocumentScene2DMaterialization', () => {
83
84
  it('materializes an empty Scene2D from a minimal model', () => {
84
- const document: FlightDocumentScene2D = {
85
+ const document = createTestDocument({
85
86
  backgroundColor: null,
86
87
  kind: 'Scene2D',
87
- resources: [],
88
88
  scene: { children: [], fields: {}, kind: DisplayObjectKind },
89
- version: 1,
90
- };
89
+ });
91
90
  const result = createFlightDocumentScene2DMaterialization(document, createTestSchemas());
92
91
  expect(result).not.toBeNull();
93
92
  const materialization = result as FlightDocumentScene2DMaterialization;
@@ -96,10 +95,9 @@ describe('createFlightDocumentScene2DMaterialization', () => {
96
95
  });
97
96
 
98
97
  it('materializes a tree with nested children', () => {
99
- const document: FlightDocumentScene2D = {
98
+ const document = createTestDocument({
100
99
  backgroundColor: null,
101
100
  kind: 'Scene2D',
102
- resources: [],
103
101
  scene: {
104
102
  children: [
105
103
  {
@@ -111,8 +109,7 @@ describe('createFlightDocumentScene2DMaterialization', () => {
111
109
  fields: {},
112
110
  kind: DisplayObjectKind,
113
111
  },
114
- version: 1,
115
- };
112
+ });
116
113
  const schemas = createTestSchemas();
117
114
  const result = createFlightDocumentScene2DMaterialization(document, schemas);
118
115
  expect(result).not.toBeNull();
@@ -126,26 +123,25 @@ describe('createFlightDocumentScene2DMaterialization', () => {
126
123
  });
127
124
 
128
125
  it('returns null for a Scene3D document', () => {
129
- const document = {
126
+ const document = createTestDocument({
130
127
  cameras: [],
131
128
  kind: 'Scene3D',
132
129
  lights: [],
133
- resources: [],
134
130
  scene: { children: [], fields: {}, kind: 'Node3D' },
135
- version: 1,
136
- } as FlightDocument;
131
+ });
137
132
  const result = createFlightDocumentScene2DMaterialization(document, createTestSchemas());
138
133
  expect(result).toBeNull();
139
134
  });
140
135
 
141
136
  it('returns null for an unsupported version', () => {
142
137
  const document = {
143
- backgroundColor: null,
144
- kind: 'Scene2D',
145
- resources: [],
146
- scene: { children: [], fields: {}, kind: DisplayObjectKind },
138
+ ...createTestDocument({
139
+ backgroundColor: null,
140
+ kind: 'Scene2D',
141
+ scene: { children: [], fields: {}, kind: DisplayObjectKind },
142
+ }),
147
143
  version: 99,
148
- } as unknown as FlightDocumentScene2D;
144
+ } as unknown as FlightDocument;
149
145
  const result = createFlightDocumentScene2DMaterialization(document, createTestSchemas());
150
146
  expect(result).toBeNull();
151
147
  });
@@ -155,12 +151,14 @@ describe('createFlightDocumentScene2DMaterializationFromText', () => {
155
151
  it('materializes a Scene2D from YAML text', () => {
156
152
  const yaml = [
157
153
  'flight: 1',
158
- 'kind: Scene2D',
159
- 'scene:',
160
- ' kind: DisplayObject',
161
- ' children:',
162
- ' - kind: Sprite',
163
- ' name: bg',
154
+ 'defaultScene: 0',
155
+ 'scenes:',
156
+ ' - kind: Scene2D',
157
+ ' scene:',
158
+ ' kind: DisplayObject',
159
+ ' children:',
160
+ ' - kind: Sprite',
161
+ ' name: bg',
164
162
  ].join('\n');
165
163
  const schemas = createTestSchemas();
166
164
  const result = createFlightDocumentScene2DMaterializationFromText(yaml, schemas);
@@ -170,7 +168,14 @@ describe('createFlightDocumentScene2DMaterializationFromText', () => {
170
168
  });
171
169
 
172
170
  it('returns null for a Scene3D YAML document', () => {
173
- const yaml = ['flight: 1', 'kind: Scene3D', 'scene:', ' kind: Node3D'].join('\n');
171
+ const yaml = [
172
+ 'flight: 1',
173
+ 'defaultScene: 0',
174
+ 'scenes:',
175
+ ' - kind: Scene3D',
176
+ ' scene:',
177
+ ' kind: Node3D',
178
+ ].join('\n');
174
179
  const result = createFlightDocumentScene2DMaterializationFromText(yaml, createTestSchemas());
175
180
  expect(result).toBeNull();
176
181
  });
@@ -184,43 +189,40 @@ describe('createFlightDocumentScene2DMaterializationFromText', () => {
184
189
 
185
190
  describe('explainFlightDocumentRefusal', () => {
186
191
  it('explains a dimension mismatch', () => {
187
- const document = {
192
+ const document = createTestDocument({
188
193
  cameras: [],
189
194
  kind: 'Scene3D',
190
195
  lights: [],
191
- resources: [],
192
196
  scene: { children: [], fields: {}, kind: 'Node3D' },
193
- version: 1,
194
- } as FlightDocument;
195
- const explanation = explainFlightDocumentRefusal(document, 'Scene2D');
197
+ });
198
+ const explanation = explainFlightDocumentRefusal(document, 'Scene2D', createTestSchemas());
196
199
  expect(explanation).not.toBeNull();
197
200
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.StructureInvalid);
198
- expect(explanation!.path).toBe('kind');
201
+ expect(explanation!.path).toBe('scenes[0].kind');
199
202
  });
200
203
 
201
204
  it('explains an unsupported version', () => {
202
205
  const document = {
203
- backgroundColor: null,
204
- kind: 'Scene2D',
205
- resources: [],
206
- scene: { children: [], fields: {}, kind: DisplayObjectKind },
206
+ ...createTestDocument({
207
+ backgroundColor: null,
208
+ kind: 'Scene2D',
209
+ scene: { children: [], fields: {}, kind: DisplayObjectKind },
210
+ }),
207
211
  version: 99,
208
- } as unknown as FlightDocumentScene2D;
209
- const explanation = explainFlightDocumentRefusal(document, 'Scene2D');
212
+ } as unknown as FlightDocument;
213
+ const explanation = explainFlightDocumentRefusal(document, 'Scene2D', createTestSchemas());
210
214
  expect(explanation).not.toBeNull();
211
215
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.VersionUnsupported);
212
216
  expect(explanation!.version).toBe(99);
213
217
  });
214
218
 
215
219
  it('returns null when the document is valid', () => {
216
- const document: FlightDocumentScene2D = {
220
+ const document = createTestDocument({
217
221
  backgroundColor: null,
218
222
  kind: 'Scene2D',
219
- resources: [],
220
223
  scene: { children: [], fields: {}, kind: DisplayObjectKind },
221
- version: 1,
222
- };
223
- const explanation = explainFlightDocumentRefusal(document, 'Scene2D');
224
+ });
225
+ const explanation = explainFlightDocumentRefusal(document, 'Scene2D', createTestSchemas());
224
226
  expect(explanation).toBeNull();
225
227
  });
226
228
  });
@@ -228,7 +230,7 @@ describe('explainFlightDocumentRefusal', () => {
228
230
  describe('explainFlightDocumentRefusalFromText', () => {
229
231
  it('explains an anchor refusal with parser position', () => {
230
232
  const yaml = 'flight: 1\nkind: Scene2D\nanchor: &ref value\n';
231
- const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D');
233
+ const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D', createTestSchemas());
232
234
  expect(explanation).not.toBeNull();
233
235
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.AnchorUnsupported);
234
236
  expect(explanation!.line).toBeGreaterThan(0);
@@ -238,21 +240,21 @@ describe('explainFlightDocumentRefusalFromText', () => {
238
240
 
239
241
  it('explains an alias refusal', () => {
240
242
  const yaml = 'flight: 1\nkind: Scene2D\nref: *alias\n';
241
- const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D');
243
+ const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D', createTestSchemas());
242
244
  expect(explanation).not.toBeNull();
243
245
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.AliasUnsupported);
244
246
  });
245
247
 
246
248
  it('explains a tag refusal', () => {
247
249
  const yaml = 'flight: 1\nkind: Scene2D\ntyped: !custom value\n';
248
- const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D');
250
+ const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D', createTestSchemas());
249
251
  expect(explanation).not.toBeNull();
250
252
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.TagUnsupported);
251
253
  });
252
254
 
253
255
  it('explains a document separator refusal', () => {
254
256
  const yaml = 'flight: 1\n---\nkind: Scene2D\n';
255
- const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D');
257
+ const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D', createTestSchemas());
256
258
  expect(explanation).not.toBeNull();
257
259
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.DocumentSeparatorUnsupported);
258
260
  });
@@ -260,7 +262,7 @@ describe('explainFlightDocumentRefusalFromText', () => {
260
262
  it('explains a key limit refusal with limit and actual', () => {
261
263
  const longKey = 'k'.repeat(300);
262
264
  const yaml = 'flight: 1\nkind: Scene2D\n' + longKey + ': value\n';
263
- const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D');
265
+ const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D', createTestSchemas());
264
266
  expect(explanation).not.toBeNull();
265
267
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.KeyCodeUnitsLimitExceeded);
266
268
  expect(explanation!.limit).toBe(256);
@@ -271,14 +273,14 @@ describe('explainFlightDocumentRefusalFromText', () => {
271
273
  });
272
274
 
273
275
  it('returns null for valid Scene2D text', () => {
274
- const yaml = 'flight: 1\nkind: Scene2D\nscene:\n kind: DisplayObject\n';
275
- const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D');
276
+ const yaml = 'flight: 1\ndefaultScene: 0\nscenes:\n - kind: Scene2D\n scene:\n kind: DisplayObject\n';
277
+ const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D', createTestSchemas());
276
278
  expect(explanation).toBeNull();
277
279
  });
278
280
 
279
281
  it('explains an unsupported version in valid YAML', () => {
280
- const yaml = 'flight: 99\nkind: Scene2D\nscene:\n kind: DisplayObject\n';
281
- const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D');
282
+ const yaml = 'flight: 99\ndefaultScene: 0\nscenes:\n - kind: Scene2D\n scene:\n kind: DisplayObject\n';
283
+ const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D', createTestSchemas());
282
284
  expect(explanation).not.toBeNull();
283
285
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.VersionUnsupported);
284
286
  expect(explanation!.path).toBe('version');
@@ -286,20 +288,20 @@ describe('explainFlightDocumentRefusalFromText', () => {
286
288
  });
287
289
 
288
290
  it('explains a dimension mismatch in valid YAML', () => {
289
- const yaml = 'flight: 1\nkind: Scene3D\nscene:\n kind: Node3D\n';
290
- const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D');
291
+ const yaml = 'flight: 1\ndefaultScene: 0\nscenes:\n - kind: Scene3D\n scene:\n kind: Node3D\n';
292
+ const explanation = explainFlightDocumentRefusalFromText(yaml, 'Scene2D', createTestSchemas());
291
293
  expect(explanation).not.toBeNull();
292
294
  expect(explanation!.reason).toBe(FlightDocumentRefusalReason.StructureInvalid);
293
- expect(explanation!.path).toBe('kind');
295
+ expect(explanation!.path).toBe('scenes[0].kind');
294
296
  });
295
297
  });
296
298
 
297
- describe('serializeFlightDocument', () => {
299
+ describe('formatFlightDocumentText', () => {
298
300
  it('round-trips a minimal Scene2D through model and text', () => {
299
301
  const scene = createScene2D();
300
302
  const schemas = createTestSchemas();
301
- const document = createFlightDocumentFromScene2D(scene, schemas);
302
- const text = serializeFlightDocument(document);
303
+ const document = createTestDocument(createFlightDocumentFromScene2D(scene, schemas));
304
+ const text = formatFlightDocumentText(document);
303
305
  expect(typeof text).toBe('string');
304
306
  expect(text).toContain('flight: 1');
305
307
  expect(text).toContain('kind: Scene2D');
@@ -312,13 +314,65 @@ describe('serializeFlightDocument', () => {
312
314
  sprite.y = 100;
313
315
  addNodeChild(scene.root, sprite);
314
316
  const schemas = createTestSchemas();
315
- const document = createFlightDocumentFromScene2D(scene, schemas);
316
- const text = serializeFlightDocument(document);
317
+ const document = createTestDocument(createFlightDocumentFromScene2D(scene, schemas));
318
+ const text = formatFlightDocumentText(document);
317
319
  const reparsed = createFlightDocumentScene2DMaterializationFromText(text, schemas);
318
320
  expect(reparsed).not.toBeNull();
319
321
  });
320
322
  });
321
323
 
324
+ describe('model-to-text explain parity', () => {
325
+ it.each<{
326
+ document: Readonly<FlightDocument>;
327
+ expectedPath: string;
328
+ expectedReason: FlightDocumentRefusalReason;
329
+ label: string;
330
+ }>([
331
+ {
332
+ label: 'wrong dimension (scene 0)',
333
+ document: createTestDocument({
334
+ cameras: [],
335
+ kind: 'Scene3D',
336
+ lights: [],
337
+ scene: { children: [], fields: {}, kind: 'Node3D' },
338
+ }),
339
+ expectedReason: FlightDocumentRefusalReason.StructureInvalid,
340
+ expectedPath: 'scenes[0].kind',
341
+ },
342
+ {
343
+ label: 'unregistered node kind (scene 0)',
344
+ document: createTestDocument({
345
+ backgroundColor: null,
346
+ kind: 'Scene2D',
347
+ scene: {
348
+ children: [{ children: [], fields: {}, kind: 'acme.Unknown' }],
349
+ fields: {},
350
+ kind: DisplayObjectKind,
351
+ },
352
+ }),
353
+ expectedReason: FlightDocumentRefusalReason.NodeKindUnregistered,
354
+ expectedPath: 'scenes[0].scene.children[0]',
355
+ },
356
+ ])('model and text explain agree on $label', ({ document, expectedPath, expectedReason }) => {
357
+ const modelResult = explainFlightDocumentRefusal(document, 'Scene2D', createTestSchemas());
358
+ const text = formatFlightDocumentText(document);
359
+ const textResult = explainFlightDocumentRefusalFromText(text, 'Scene2D', createTestSchemas());
360
+ expect(modelResult).not.toBeNull();
361
+ expect(textResult).not.toBeNull();
362
+ expect(textResult!.reason).toBe(modelResult!.reason);
363
+ expect(textResult!.path).toBe(modelResult!.path);
364
+ expect(modelResult!.reason).toBe(expectedReason);
365
+ expect(modelResult!.path).toBe(expectedPath);
366
+ });
367
+ });
368
+
369
+ function createTestDocument(
370
+ scene: FlightDocumentScene,
371
+ resources: FlightDocumentResourceDescriptor[] = [],
372
+ ): FlightDocument {
373
+ return { defaultScene: 0, resources, scenes: [scene], version: 1 };
374
+ }
375
+
322
376
  function createTestSchemas(): FlightDocumentSchemaRegistry {
323
377
  const displayObjectSchema: FlightDocumentNodeSchema = {
324
378
  createNode: (fields: Readonly<FlightDocumentFields>, _resources: FlightDocumentResourceLookup) => {
@@ -393,3 +447,75 @@ const TRANSFORM_FIELD_SCHEMAS = [
393
447
  { defaultValue: 0, name: 'rotation', required: false, validate: (v: unknown) => typeof v === 'number' },
394
448
  { defaultValue: 1, name: 'alpha', required: false, validate: (v: unknown) => typeof v === 'number' },
395
449
  ] as const;
450
+
451
+ // ★ ROOT-NODE FIDELITY. The writer captures the root's kind and fields (`writeNode(source.root, …)`), but
452
+ // the reader built a fresh container with `createScene2D()` and materialized only `scene.children` — so
453
+ // everything authored ON the root was silently dropped. Write preserved what read discarded, which makes
454
+ // the round trip lossy in a way no per-child test could see.
455
+ describe('Scene2D root fidelity', () => {
456
+ it('preserves the authored root kind', () => {
457
+ const document = rootDocument2D(SpriteKind, {});
458
+ const materialization = createFlightDocumentScene2DMaterialization(document, createTestSchemas());
459
+ expect(materialization).not.toBeNull();
460
+ expect(materialization!.scene.root.kind).toBe(SpriteKind);
461
+ });
462
+
463
+ it('preserves fields authored on the root', () => {
464
+ const document = rootDocument2D(DisplayObjectKind, { x: 100, y: 200 });
465
+ const materialization = createFlightDocumentScene2DMaterialization(document, createTestSchemas());
466
+ expect(materialization).not.toBeNull();
467
+ const root = materialization!.scene.root as unknown as { x: number; y: number };
468
+ expect(root.x).toBe(100);
469
+ expect(root.y).toBe(200);
470
+ });
471
+
472
+ it('round-trips a root with authored fields without losing them', () => {
473
+ const schemas = createTestSchemas();
474
+ const scene = createScene2D();
475
+ (scene.root as unknown as { x: number; y: number }).x = 7;
476
+ (scene.root as unknown as { x: number; y: number }).y = 9;
477
+
478
+ const document = createFlightDocumentFromScene2D(scene, schemas);
479
+ const wrapped = { defaultScene: 0, resources: [], scenes: [document], version: 1 } as unknown as FlightDocument;
480
+ const back = createFlightDocumentScene2DMaterialization(wrapped, schemas);
481
+
482
+ expect(back).not.toBeNull();
483
+ const root = back!.scene.root as unknown as { x: number; y: number };
484
+ expect(root.x).toBe(7);
485
+ expect(root.y).toBe(9);
486
+ });
487
+
488
+ // ★ A REGISTERED kind of the WRONG DIMENSION is the interesting refusal: it is not an unregistered kind,
489
+ // so the unregistered-kind check passes it through, and it then builds a Node3D as a 2D scene root.
490
+ it('refuses a registered root whose kind belongs to the other dimension', () => {
491
+ // The kind must be REGISTERED for this to be the case under test — an unregistered kind is caught one
492
+ // check earlier, and that is a different refusal with a different remedy.
493
+ const schemas = createTestSchemas();
494
+ schemas.nodeSchemas = withRegistryTableEntry(schemas.nodeSchemas, Node3DKind, {
495
+ createNode: () => createNode3D() as unknown as NodeAny,
496
+ fields: [],
497
+ kind: Node3DKind,
498
+ writeNodeFields: () => true,
499
+ });
500
+ const document = rootDocument2D(Node3DKind, {});
501
+ expect(createFlightDocumentScene2DMaterialization(document, schemas)).toBeNull();
502
+ expect(explainFlightDocumentRefusal(document, 'Scene2D', schemas)).toMatchObject({
503
+ reason: FlightDocumentRefusalReason.RootKindMismatch,
504
+ });
505
+ });
506
+ });
507
+
508
+ function rootDocument2D(kind: string, fields: Record<string, unknown>): FlightDocument {
509
+ return {
510
+ defaultScene: 0,
511
+ resources: [],
512
+ scenes: [
513
+ {
514
+ backgroundColor: null,
515
+ kind: 'Scene2D',
516
+ scene: { children: [], fields, kind },
517
+ },
518
+ ],
519
+ version: 1,
520
+ } as unknown as FlightDocument;
521
+ }