@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flighthq/scene-document",
3
- "version": "0.4.1-edge.37.ff47e13",
3
+ "version": "0.4.1-next.109.0f3397e",
4
4
  "author": "Joshua Granick and other contributors",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,14 +38,14 @@
38
38
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@flighthq/camera": "0.4.1-edge.37.ff47e13",
42
- "@flighthq/geometry": "0.4.1-edge.37.ff47e13",
43
- "@flighthq/lighting": "0.4.1-edge.37.ff47e13",
44
- "@flighthq/node": "0.4.1-edge.37.ff47e13",
45
- "@flighthq/registry": "0.4.1-edge.37.ff47e13",
46
- "@flighthq/scene2d": "0.4.1-edge.37.ff47e13",
47
- "@flighthq/scene3d": "0.4.1-edge.37.ff47e13",
48
- "@flighthq/types": "0.4.1-edge.37.ff47e13"
41
+ "@flighthq/camera": "0.4.1-next.109.0f3397e",
42
+ "@flighthq/geometry": "0.4.1-next.109.0f3397e",
43
+ "@flighthq/lighting": "0.4.1-next.109.0f3397e",
44
+ "@flighthq/node": "0.4.1-next.109.0f3397e",
45
+ "@flighthq/registry": "0.4.1-next.109.0f3397e",
46
+ "@flighthq/scene2d": "0.4.1-next.109.0f3397e",
47
+ "@flighthq/scene3d": "0.4.1-next.109.0f3397e",
48
+ "@flighthq/types": "0.4.1-next.109.0f3397e"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@flighthq/camera": "*",
@@ -0,0 +1,179 @@
1
+ import { createTransform3D } from '@flighthq/geometry/contract';
2
+ import { createAmbientLight } from '@flighthq/lighting/contract';
3
+ import type { FlightDocument } from '@flighthq/types/contract';
4
+ import { FlightDocumentRefusalReason } from '@flighthq/types/contract';
5
+
6
+ import { explainFlightDocumentText, formatFlightDocumentText, parseFlightDocumentText } from './flightDocumentText';
7
+ import { parseSceneDocumentYamlSubset } from './sceneDocumentYamlSubset';
8
+
9
+ describe('explainFlightDocumentText', () => {
10
+ it('names an empty scenes collection independently', () => {
11
+ const explanation = explainFlightDocumentText('flight: 1\ndefaultScene: 0\nscenes:\n');
12
+
13
+ expect(explanation).toMatchObject({
14
+ path: 'scenes',
15
+ reason: FlightDocumentRefusalReason.ScenesEmpty,
16
+ });
17
+ });
18
+
19
+ it('names an out-of-range default scene without clamping it', () => {
20
+ const explanation = explainFlightDocumentText(
21
+ ['flight: 1', 'defaultScene: 1', 'scenes:', ' - kind: Scene2D', ' scene:', ' kind: DisplayObject'].join(
22
+ '\n',
23
+ ),
24
+ );
25
+
26
+ expect(explanation).toMatchObject({
27
+ actual: 1,
28
+ limit: 0,
29
+ path: 'defaultScene',
30
+ reason: FlightDocumentRefusalReason.DefaultSceneOutOfRange,
31
+ });
32
+ });
33
+
34
+ it('qualifies a structural refusal with the nonzero scene index', () => {
35
+ const explanation = explainFlightDocumentText(
36
+ [
37
+ 'flight: 1',
38
+ 'defaultScene: 0',
39
+ 'scenes:',
40
+ ' - kind: Scene2D',
41
+ ' scene:',
42
+ ' kind: DisplayObject',
43
+ ' - kind: Scene3D',
44
+ ' scene:',
45
+ ' children:',
46
+ ' - kind: Node3D',
47
+ ].join('\n'),
48
+ );
49
+
50
+ expect(explanation).toMatchObject({
51
+ path: 'scenes[1].scene.kind',
52
+ reason: FlightDocumentRefusalReason.StructureInvalid,
53
+ });
54
+ });
55
+
56
+ it('preserves a typed YAML-subset refusal with source position', () => {
57
+ const explanation = explainFlightDocumentText('flight: 1\nanchor: &root value\n');
58
+
59
+ expect(explanation).toMatchObject({
60
+ reason: FlightDocumentRefusalReason.AnchorUnsupported,
61
+ });
62
+ expect(explanation?.line).toBe(2);
63
+ expect(explanation?.column).toBeGreaterThan(0);
64
+ });
65
+ });
66
+
67
+ describe('formatFlightDocumentText', () => {
68
+ it('emits only the accepted YAML subset and round-trips a mixed-scene container', () => {
69
+ const cameraTransform = createTransform3D();
70
+ cameraTransform.position.z = 8;
71
+ const lightTransform = createTransform3D();
72
+ lightTransform.position.y = 4;
73
+ const document: FlightDocument = {
74
+ defaultScene: 1,
75
+ resources: [{ fields: { source: 'shared image.png' }, key: 'shared', kind: 'Texture' }],
76
+ scenes: [
77
+ {
78
+ backgroundColor: 0x112233ff,
79
+ kind: 'Scene2D',
80
+ scene: {
81
+ children: [
82
+ {
83
+ children: [],
84
+ fields: { metadata: { enabled: true }, tags: ['hud', 'shared'], texture: 'shared' },
85
+ kind: 'Sprite',
86
+ },
87
+ ],
88
+ fields: {},
89
+ kind: 'DisplayObject',
90
+ },
91
+ },
92
+ {
93
+ cameras: [
94
+ {
95
+ far: 1000,
96
+ name: 'main camera',
97
+ near: 0.1,
98
+ projection: { aspect: 16 / 9, fovY: 1, kind: 'perspective' },
99
+ transform: cameraTransform,
100
+ },
101
+ ],
102
+ kind: 'Scene3D',
103
+ lights: [{ descriptor: createAmbientLight({ intensity: 0.4 }), transform: lightTransform }],
104
+ scene: { children: [], fields: {}, kind: 'Node3D' },
105
+ },
106
+ ],
107
+ version: 1,
108
+ };
109
+
110
+ const text = formatFlightDocumentText(document);
111
+ expect(parseSceneDocumentYamlSubset(text).ok).toBe(true);
112
+ expect(text).not.toContain('[');
113
+ expect(text).not.toContain('&');
114
+
115
+ const reparsed = parseFlightDocumentText(text);
116
+ expect(reparsed).not.toBeNull();
117
+ expect(reparsed?.defaultScene).toBe(1);
118
+ expect(reparsed?.resources).toEqual(document.resources);
119
+ expect(reparsed?.scenes.map((scene) => scene.kind)).toEqual(['Scene2D', 'Scene3D']);
120
+ expect(reparsed?.scenes[0].scene.children[0]?.fields).toEqual(document.scenes[0].scene.children[0]?.fields);
121
+ const scene3D = reparsed?.scenes[1];
122
+ expect(scene3D?.kind).toBe('Scene3D');
123
+ if (scene3D?.kind !== 'Scene3D') throw new Error('expected Scene3D fixture');
124
+ expect(scene3D.cameras[0]?.name).toBe('main camera');
125
+ expect(scene3D.cameras[0]?.transform.position.z).toBe(8);
126
+ expect(scene3D.lights[0]?.descriptor.kind).toBe('AmbientLight');
127
+ expect(Reflect.get(scene3D.lights[0]?.descriptor ?? {}, 'intensity')).toBe(0.4);
128
+ });
129
+ });
130
+
131
+ describe('parseFlightDocumentText', () => {
132
+ it('parses mixed dimensions over one shared resource table', () => {
133
+ const document = parseFlightDocumentText(
134
+ [
135
+ 'flight: 1',
136
+ 'defaultScene: 1',
137
+ 'resources:',
138
+ ' - kind: Texture',
139
+ ' key: shared',
140
+ ' source: shared.png',
141
+ 'scenes:',
142
+ ' - kind: Scene2D',
143
+ ' backgroundColor: 287454207',
144
+ ' scene:',
145
+ ' kind: DisplayObject',
146
+ ' children:',
147
+ ' - kind: Sprite',
148
+ ' texture: shared',
149
+ ' - kind: Scene3D',
150
+ ' scene:',
151
+ ' kind: Node3D',
152
+ ' children:',
153
+ ' - kind: Mesh',
154
+ ' texture: shared',
155
+ ].join('\n'),
156
+ );
157
+
158
+ expect(document).not.toBeNull();
159
+ expect(document?.defaultScene).toBe(1);
160
+ expect(document?.resources).toEqual([{ fields: { source: 'shared.png' }, key: 'shared', kind: 'Texture' }]);
161
+ expect(document?.scenes.map((scene) => scene.kind)).toEqual(['Scene2D', 'Scene3D']);
162
+ expect(document?.scenes[0].scene.children[0]?.fields['texture']).toBe('shared');
163
+ expect(document?.scenes[1].scene.children[0]?.fields['texture']).toBe('shared');
164
+ });
165
+
166
+ it('returns null whenever the explain seam reports a structural refusal', () => {
167
+ const text = [
168
+ 'flight: 1',
169
+ 'defaultScene: 2',
170
+ 'scenes:',
171
+ ' - kind: Scene2D',
172
+ ' scene:',
173
+ ' kind: DisplayObject',
174
+ ].join('\n');
175
+
176
+ expect(parseFlightDocumentText(text)).toBeNull();
177
+ expect(explainFlightDocumentText(text)?.reason).toBe(FlightDocumentRefusalReason.DefaultSceneOutOfRange);
178
+ });
179
+ });
@@ -0,0 +1,56 @@
1
+ import type { FlightDocument } from '@flighthq/types/contract';
2
+ import { DisplayObjectKind, FlightDocumentRefusalReason, Node3DKind } from '@flighthq/types/contract';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { selectFlightDocumentScene } from './sceneDocumentMaterializationSelection';
6
+
7
+ describe('selectFlightDocumentScene', () => {
8
+ it('selects the default or requested scene when its dimension matches', () => {
9
+ const document = createDocument();
10
+
11
+ expect(selectFlightDocumentScene(document, 'Scene3D')).toMatchObject({
12
+ refusal: null,
13
+ scene: document.scenes[1],
14
+ sceneIndex: 1,
15
+ });
16
+ expect(selectFlightDocumentScene(document, 'Scene2D', 0)).toMatchObject({
17
+ refusal: null,
18
+ scene: document.scenes[0],
19
+ sceneIndex: 0,
20
+ });
21
+ });
22
+
23
+ it('refuses invalid containers and mismatched dimensions', () => {
24
+ const emptyDocument = { ...createDocument(), scenes: [] } as unknown as FlightDocument;
25
+
26
+ expect(selectFlightDocumentScene(emptyDocument, 'Scene2D')).toMatchObject({
27
+ refusal: { path: 'scenes', reason: FlightDocumentRefusalReason.ScenesEmpty },
28
+ scene: null,
29
+ });
30
+ expect(selectFlightDocumentScene(createDocument(), 'Scene2D')).toMatchObject({
31
+ refusal: { path: 'scenes[1].kind', reason: FlightDocumentRefusalReason.StructureInvalid },
32
+ scene: null,
33
+ });
34
+ });
35
+ });
36
+
37
+ function createDocument(): FlightDocument {
38
+ return {
39
+ defaultScene: 1,
40
+ resources: [],
41
+ scenes: [
42
+ {
43
+ backgroundColor: null,
44
+ kind: 'Scene2D',
45
+ scene: { children: [], fields: {}, kind: DisplayObjectKind },
46
+ },
47
+ {
48
+ cameras: [],
49
+ kind: 'Scene3D',
50
+ lights: [],
51
+ scene: { children: [], fields: {}, kind: Node3DKind },
52
+ },
53
+ ],
54
+ version: 1,
55
+ };
56
+ }
@@ -0,0 +1,209 @@
1
+ import { createTransform3D } from '@flighthq/geometry/contract';
2
+ import { createAmbientLight } from '@flighthq/lighting/contract';
3
+ import { createKeyedTable, withRegistryTableEntry } from '@flighthq/registry/contract';
4
+ import { createDisplayObject } from '@flighthq/scene2d/contract';
5
+ import { createNode3D } from '@flighthq/scene3d/contract';
6
+ import type {
7
+ FlightDocument,
8
+ FlightDocumentFields,
9
+ FlightDocumentNodeSchema,
10
+ FlightDocumentResourceLookup,
11
+ FlightDocumentResourceResolver,
12
+ FlightDocumentResourceResolverRegistry,
13
+ FlightDocumentSchemaRegistry,
14
+ NodeAny,
15
+ } from '@flighthq/types/contract';
16
+ import { DisplayObjectKind, FlightDocumentRefusalReason, Node3DKind } from '@flighthq/types/contract';
17
+ import { describe, expect, it } from 'vitest';
18
+
19
+ import { formatFlightDocumentText } from './flightDocumentText';
20
+ import {
21
+ createFlightDocumentScene2DMaterialization,
22
+ createFlightDocumentScene2DMaterializationFromText,
23
+ explainFlightDocumentRefusal,
24
+ explainFlightDocumentRefusalFromText,
25
+ } from './sceneDocumentScene2DMaterialization';
26
+ import {
27
+ createFlightDocumentScene3DMaterialization,
28
+ createFlightDocumentScene3DMaterializationFromText,
29
+ explainFlightDocumentScene3DRefusal,
30
+ } from './sceneDocumentScene3DMaterialization';
31
+
32
+ const TestScene2DNodeKind = 'TestScene2DNode';
33
+ const TestScene3DNodeKind = 'TestScene3DNode';
34
+
35
+ describe('multi-scene materialization selection', () => {
36
+ it('uses defaultScene by default and can materialize another entry by index', () => {
37
+ const seenResources: unknown[] = [];
38
+ const schemas = createTestSchemas(seenResources);
39
+ const sharedResource = {};
40
+ const resolvers = createTestResolvers(sharedResource);
41
+ const document = createMixedDocument();
42
+
43
+ const selected3D = createFlightDocumentScene3DMaterialization(document, schemas, resolvers);
44
+ const selectedAs2D = createFlightDocumentScene2DMaterialization(document, schemas, resolvers);
45
+ const perScene2D = createFlightDocumentScene2DMaterialization(document, schemas, resolvers, 0);
46
+ const wrongPerScene3D = createFlightDocumentScene3DMaterialization(document, schemas, resolvers, 0);
47
+
48
+ expect(selected3D).not.toBeNull();
49
+ expect(selectedAs2D).toBeNull();
50
+ expect(perScene2D).not.toBeNull();
51
+ expect(wrongPerScene3D).toBeNull();
52
+ expect(seenResources).toEqual([sharedResource, sharedResource]);
53
+ expect(seenResources[0]).toBe(seenResources[1]);
54
+ });
55
+
56
+ it('preserves selected and explicit-index semantics through the text conveniences', () => {
57
+ const seenResources: unknown[] = [];
58
+ const schemas = createTestSchemas(seenResources);
59
+ const sharedResource = {};
60
+ const resolvers = createTestResolvers(sharedResource);
61
+ const text = formatFlightDocumentText(createMixedDocument());
62
+
63
+ expect(createFlightDocumentScene3DMaterializationFromText(text, schemas, resolvers)).not.toBeNull();
64
+ expect(createFlightDocumentScene2DMaterializationFromText(text, schemas, resolvers)).toBeNull();
65
+ expect(createFlightDocumentScene2DMaterializationFromText(text, schemas, resolvers, 0)).not.toBeNull();
66
+ expect(explainFlightDocumentRefusalFromText(text, 'Scene2D', schemas, 1)).toMatchObject({
67
+ path: 'scenes[1].kind',
68
+ reason: FlightDocumentRefusalReason.StructureInvalid,
69
+ });
70
+ expect(seenResources).toEqual([sharedResource, sharedResource]);
71
+ });
72
+
73
+ it('rejects an empty scene container through both dimension entrypoints', () => {
74
+ const document = { defaultScene: 0, resources: [], scenes: [], version: 1 } as unknown as FlightDocument;
75
+ const schemas = createTestSchemas([]);
76
+
77
+ expect(createFlightDocumentScene2DMaterialization(document, schemas)).toBeNull();
78
+ expect(createFlightDocumentScene3DMaterialization(document, schemas)).toBeNull();
79
+ expect(explainFlightDocumentRefusal(document, 'Scene2D', schemas)).toMatchObject({
80
+ path: 'scenes',
81
+ reason: FlightDocumentRefusalReason.ScenesEmpty,
82
+ });
83
+ expect(explainFlightDocumentScene3DRefusal(document, schemas)).toMatchObject({
84
+ path: 'scenes',
85
+ reason: FlightDocumentRefusalReason.ScenesEmpty,
86
+ });
87
+ });
88
+
89
+ it('rejects an invalid defaultScene instead of clamping it even for an explicit scene request', () => {
90
+ const document: FlightDocument = { ...createMixedDocument(), defaultScene: 2 };
91
+ const schemas = createTestSchemas([]);
92
+
93
+ expect(createFlightDocumentScene2DMaterialization(document, schemas, undefined, 0)).toBeNull();
94
+ expect(explainFlightDocumentRefusal(document, 'Scene2D', schemas, 0)).toMatchObject({
95
+ actual: 2,
96
+ path: 'defaultScene',
97
+ reason: FlightDocumentRefusalReason.DefaultSceneOutOfRange,
98
+ });
99
+ });
100
+
101
+ it('qualifies nested and dimension-specific refusals with the requested scene index', () => {
102
+ const document = createMixedDocument();
103
+ const schemas = createTestSchemas([]);
104
+
105
+ expect(explainFlightDocumentRefusal(document, 'Scene2D', schemas, 1)).toMatchObject({
106
+ path: 'scenes[1].kind',
107
+ reason: FlightDocumentRefusalReason.StructureInvalid,
108
+ });
109
+
110
+ const scene = document.scenes[1];
111
+ if (scene.kind !== 'Scene3D') throw new Error('fixture scene 1 must be Scene3D');
112
+ scene.scene.children = [{ children: [], fields: {}, kind: 'acme.Unknown' }];
113
+
114
+ expect(explainFlightDocumentScene3DRefusal(document, schemas, 1)).toMatchObject({
115
+ kind: 'acme.Unknown',
116
+ path: 'scenes[1].scene.children[0]',
117
+ reason: FlightDocumentRefusalReason.NodeKindUnregistered,
118
+ });
119
+
120
+ scene.scene.children = [];
121
+ scene.lights = [
122
+ { descriptor: createAmbientLight(), transform: createTransform3D() },
123
+ { descriptor: createAmbientLight(), transform: createTransform3D() },
124
+ ];
125
+ expect(explainFlightDocumentScene3DRefusal(document, schemas, 1)).toMatchObject({
126
+ path: 'scenes[1].lights',
127
+ reason: FlightDocumentRefusalReason.DuplicateAmbientLight,
128
+ });
129
+ });
130
+ });
131
+
132
+ function createMixedDocument(): FlightDocument {
133
+ return {
134
+ defaultScene: 1,
135
+ resources: [{ fields: {}, key: 'shared', kind: 'TestResource' }],
136
+ scenes: [
137
+ {
138
+ backgroundColor: null,
139
+ kind: 'Scene2D',
140
+ scene: {
141
+ children: [{ children: [], fields: {}, kind: TestScene2DNodeKind }],
142
+ fields: {},
143
+ kind: DisplayObjectKind,
144
+ },
145
+ },
146
+ {
147
+ cameras: [],
148
+ kind: 'Scene3D',
149
+ lights: [],
150
+ scene: {
151
+ children: [{ children: [], fields: {}, kind: TestScene3DNodeKind }],
152
+ fields: {},
153
+ kind: Node3DKind,
154
+ },
155
+ },
156
+ ],
157
+ version: 1,
158
+ };
159
+ }
160
+
161
+ function createTestResolvers(sharedResource: unknown): FlightDocumentResourceResolverRegistry {
162
+ let resolvers = createKeyedTable<FlightDocumentResourceResolver>('flight-document.resolver', 'none');
163
+ resolvers = withRegistryTableEntry(resolvers, 'TestResource', () => sharedResource);
164
+ return { resolvers };
165
+ }
166
+
167
+ function createTestSchemas(seenResources: unknown[]): FlightDocumentSchemaRegistry {
168
+ const createSchema = (
169
+ kind: string,
170
+ createNode: (resources: FlightDocumentResourceLookup) => NodeAny,
171
+ ): FlightDocumentNodeSchema => ({
172
+ createNode: (_fields, resources) => createNode(resources),
173
+ fields: [],
174
+ kind,
175
+ writeNodeFields: (_out: FlightDocumentFields, _source: Readonly<NodeAny>) => true,
176
+ });
177
+ let nodeSchemas = createKeyedTable<FlightDocumentNodeSchema>('flight-document.node', 'none');
178
+ nodeSchemas = withRegistryTableEntry(
179
+ nodeSchemas,
180
+ DisplayObjectKind,
181
+ createSchema(DisplayObjectKind, () => createDisplayObject()),
182
+ );
183
+ nodeSchemas = withRegistryTableEntry(
184
+ nodeSchemas,
185
+ TestScene2DNodeKind,
186
+ createSchema(TestScene2DNodeKind, (resources) => {
187
+ seenResources.push(resources['shared']);
188
+ return createDisplayObject();
189
+ }),
190
+ );
191
+ nodeSchemas = withRegistryTableEntry(
192
+ nodeSchemas,
193
+ Node3DKind,
194
+ createSchema(Node3DKind, () => createNode3D()),
195
+ );
196
+ nodeSchemas = withRegistryTableEntry(
197
+ nodeSchemas,
198
+ TestScene3DNodeKind,
199
+ createSchema(TestScene3DNodeKind, (resources) => {
200
+ seenResources.push(resources['shared']);
201
+ return createNode3D();
202
+ }),
203
+ );
204
+ return {
205
+ nodeSchemas,
206
+ resourceSchemas: createKeyedTable('flight-document.resource', 'none'),
207
+ shapeCommandSchemas: createKeyedTable('flight-document.shape-command', 'none'),
208
+ };
209
+ }
@@ -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
+ }