@forgeax/engine-pack 0.1.28 → 0.1.30
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/README.md +33 -5
- package/dist/build.mjs +99 -60
- package/dist/build.mjs.map +1 -1
- package/dist/catalog-projection.d.ts +3 -3
- package/dist/cli-asset.mjs +22 -13
- package/dist/cli-asset.mjs.map +1 -1
- package/dist/evidence/material-cook.d.ts +7 -1
- package/dist/evidence/material-cook.d.ts.map +1 -1
- package/dist/index.mjs +61 -5
- package/dist/index.mjs.map +1 -1
- package/dist/inventory/declaration.d.ts +1 -1
- package/dist/inventory/declaration.d.ts.map +1 -1
- package/dist/inventory/sync.d.ts.map +1 -1
- package/dist/material-cook.mjs +61 -5
- package/dist/material-cook.mjs.map +1 -1
- package/dist/native-cooker-registry.d.ts +1 -0
- package/dist/native-cooker-registry.d.ts.map +1 -1
- package/dist/native-cooker.mjs.map +1 -1
- package/dist/pack-authoring-node.mjs +22 -13
- package/dist/pack-authoring-node.mjs.map +1 -1
- package/dist/pack-authoring.d.ts +8 -2
- package/dist/pack-authoring.d.ts.map +1 -1
- package/dist/pack-authoring.mjs.map +1 -1
- package/dist/runtime-publication.d.ts +2 -0
- package/dist/runtime-publication.d.ts.map +1 -1
- package/dist/runtime.mjs.map +1 -1
- package/dist/scanner.mjs +22 -13
- package/dist/scanner.mjs.map +1 -1
- package/dist/schema-compiled.d.ts.map +1 -1
- package/dist/schema.mjs +40 -62
- package/dist/schema.mjs.map +1 -1
- package/dist/scriptable-pack-node.d.ts.map +1 -1
- package/dist/scriptable-pack-node.mjs +12 -4
- package/dist/scriptable-pack-node.mjs.map +1 -1
- package/dist/scriptable-pack-worker.mjs +12 -5
- package/dist/scriptable-pack-worker.mjs.map +1 -1
- package/dist/scriptable-pack.d.ts +2 -1
- package/dist/scriptable-pack.d.ts.map +1 -1
- package/dist/scriptable-pack.mjs.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/inventory-schema.test.ts +10 -10
- package/src/__tests__/material-cook-schema.unit.test.ts +92 -1
- package/src/__tests__/pack-authoring.unit.test.ts +2 -4
- package/src/__tests__/pack.unit.test.ts +86 -124
- package/src/__tests__/runtime-publication.unit.test.ts +36 -0
- package/src/__tests__/scanner-instance-cycle.test.ts +92 -0
- package/src/__tests__/scanner-inventory.contract.test.ts +2 -2
- package/src/__tests__/scriptable-pack-cli.integration.test.ts +35 -1
- package/src/__tests__/scriptable-pack-diagnostic.integration.test.ts +45 -0
- package/src/evidence/material-cook.ts +79 -5
- package/src/inventory/binding.ts +3 -3
- package/src/inventory/declaration.ts +10 -48
- package/src/inventory/sync.ts +3 -2
- package/src/native-cooker-registry.ts +1 -0
- package/src/pack-authoring.ts +11 -2
- package/src/runtime-publication.ts +7 -3
- package/src/scanner.ts +28 -28
- package/src/schema/material-cook.schema.json +107 -0
- package/src/schema-compiled.ts +40 -79
- package/src/scriptable-pack-node.ts +16 -4
- package/src/scriptable-pack-worker.ts +23 -5
- package/src/scriptable-pack.ts +2 -1
- package/src/__tests__/scanner-mount-cycle.test.ts +0 -232
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { deriveStandardLayerPlan } from '@forgeax/engine-types';
|
|
3
|
+
import { deriveStandardLayerPlan, type MaterialProgramAbi } from '@forgeax/engine-types';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
import {
|
|
6
6
|
type CookedMaterialRecord,
|
|
@@ -82,6 +82,23 @@ const record: CookedMaterialRecord = {
|
|
|
82
82
|
},
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
+
const submissionAbi: MaterialProgramAbi = {
|
|
86
|
+
directEntry: 'vs_main',
|
|
87
|
+
sceneIndexEntry: 'vs_scene_index',
|
|
88
|
+
materialRow: { byteLength: 16, fields: ['baseColor'] },
|
|
89
|
+
resourceSlots: [],
|
|
90
|
+
uvSets: [],
|
|
91
|
+
vertexInputs: [{ semantic: 'position', location: 0, format: 'float32x3' }],
|
|
92
|
+
alphaMask: { cutoff: 'alphaCutoff', source: 'baseColor.a' },
|
|
93
|
+
reflection: {
|
|
94
|
+
layoutIdentity: 'sha256:layout',
|
|
95
|
+
resourceSlots: [],
|
|
96
|
+
vertexInputs: [{ semantic: 'position', location: 0, format: 'float32x3' }],
|
|
97
|
+
},
|
|
98
|
+
receiptIdentity: 'custom/layout',
|
|
99
|
+
generation: 1,
|
|
100
|
+
};
|
|
101
|
+
|
|
85
102
|
function seal(value: CookedMaterialRecord): CookedMaterialRecord {
|
|
86
103
|
return {
|
|
87
104
|
...value,
|
|
@@ -215,6 +232,80 @@ describe('cooked material schema', () => {
|
|
|
215
232
|
expect(validateCookedMaterialRecord(shared)).toMatchObject({ ok: true });
|
|
216
233
|
});
|
|
217
234
|
|
|
235
|
+
it('keeps direct and scene-index selections unique under one published ABI', () => {
|
|
236
|
+
const primary = record.programs[0] as MaterialCookProgram;
|
|
237
|
+
const context = primary.selections[0]?.context;
|
|
238
|
+
if (context === undefined) throw new Error('fixture context is missing');
|
|
239
|
+
const modern = seal({
|
|
240
|
+
...record,
|
|
241
|
+
programs: [
|
|
242
|
+
{
|
|
243
|
+
...primary,
|
|
244
|
+
selections: [
|
|
245
|
+
{
|
|
246
|
+
pass: 'forward',
|
|
247
|
+
context,
|
|
248
|
+
address: 'direct',
|
|
249
|
+
entry: submissionAbi.directEntry,
|
|
250
|
+
abi: submissionAbi,
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
pass: 'forward',
|
|
254
|
+
context,
|
|
255
|
+
address: 'scene-index',
|
|
256
|
+
entry: submissionAbi.sceneIndexEntry,
|
|
257
|
+
abi: submissionAbi,
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
expect(validateCookedMaterialRecord(modern)).toMatchObject({ ok: true });
|
|
264
|
+
expect(
|
|
265
|
+
validateCookedMaterialRecord({
|
|
266
|
+
...modern,
|
|
267
|
+
programs: [{ ...modern.programs[0], selections: [modern.programs[0]?.selections[0]] }],
|
|
268
|
+
}),
|
|
269
|
+
).toMatchObject({
|
|
270
|
+
ok: false,
|
|
271
|
+
error: { detail: { actual: expect.stringContaining('incomplete submission address pair') } },
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('rejects a partially modern publication instead of assigning missing address facts to direct', () => {
|
|
276
|
+
const primary = record.programs[0] as MaterialCookProgram;
|
|
277
|
+
const context = primary.selections[0]?.context;
|
|
278
|
+
if (context === undefined) throw new Error('fixture context is missing');
|
|
279
|
+
const passes = [...record.resolved.passes, { name: 'second', program: { module: 'other' } }];
|
|
280
|
+
const modern = seal({
|
|
281
|
+
...record,
|
|
282
|
+
resolved: { ...record.resolved, passes },
|
|
283
|
+
programs: [
|
|
284
|
+
{
|
|
285
|
+
...primary,
|
|
286
|
+
selections: [
|
|
287
|
+
{
|
|
288
|
+
pass: 'forward',
|
|
289
|
+
context,
|
|
290
|
+
address: 'direct',
|
|
291
|
+
entry: submissionAbi.directEntry,
|
|
292
|
+
abi: submissionAbi,
|
|
293
|
+
},
|
|
294
|
+
{ pass: 'second', context },
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
});
|
|
299
|
+
expect(validateCookedMaterialRecord(modern)).toMatchObject({
|
|
300
|
+
ok: false,
|
|
301
|
+
error: {
|
|
302
|
+
detail: {
|
|
303
|
+
actual: expect.stringContaining('modern material publications require address'),
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
218
309
|
it('rejects ambiguous selections, unpublished passes and unknown context axes', () => {
|
|
219
310
|
const primary = record.programs[0] as MaterialCookProgram;
|
|
220
311
|
expect(
|
|
@@ -38,8 +38,7 @@ describe('ScriptablePack and Pack authoring', () => {
|
|
|
38
38
|
ok({
|
|
39
39
|
'scene/main': {
|
|
40
40
|
kind: 'scene',
|
|
41
|
-
entities:
|
|
42
|
-
mounts: [],
|
|
41
|
+
entities: {},
|
|
43
42
|
source: AssetGuid.format(AssetGuid.derive(subjectId, 'mesh/main')),
|
|
44
43
|
},
|
|
45
44
|
}),
|
|
@@ -62,8 +61,7 @@ describe('ScriptablePack and Pack authoring', () => {
|
|
|
62
61
|
ok({
|
|
63
62
|
'scene/main': {
|
|
64
63
|
kind: 'scene',
|
|
65
|
-
entities: values.segments >= 0 ?
|
|
66
|
-
mounts: [],
|
|
64
|
+
entities: values.segments >= 0 ? {} : {},
|
|
67
65
|
},
|
|
68
66
|
}),
|
|
69
67
|
});
|
|
@@ -628,7 +628,7 @@ const V1_WHITELIST = new Set([
|
|
|
628
628
|
type: 'object',
|
|
629
629
|
additionalProperties: false,
|
|
630
630
|
required: ['parent'],
|
|
631
|
-
properties: { parent: { type: '
|
|
631
|
+
properties: { parent: { type: 'string', minLength: 1 } },
|
|
632
632
|
} as const;
|
|
633
633
|
const componentSchemas: Record<string, object> = {
|
|
634
634
|
Transform: TRANSFORM_SCHEMA,
|
|
@@ -639,58 +639,62 @@ const V1_WHITELIST = new Set([
|
|
|
639
639
|
|
|
640
640
|
describe('scene-schema.test.ts', () => {
|
|
641
641
|
describe('SceneAsset payload schema - positive cases (w4 / D-P4)', () => {
|
|
642
|
-
it('accepts a valid
|
|
642
|
+
it('accepts a valid keyed payload (Transform + MeshFilter + ChildOf)', () => {
|
|
643
643
|
const payload = {
|
|
644
644
|
kind: 'scene',
|
|
645
|
-
entities:
|
|
646
|
-
{
|
|
647
|
-
localId: 0,
|
|
645
|
+
entities: {
|
|
646
|
+
root: {
|
|
648
647
|
components: {
|
|
649
648
|
Transform: { pos: [1, 2, 3] },
|
|
650
649
|
MeshFilter: { mesh: 'cube' },
|
|
651
650
|
},
|
|
652
651
|
},
|
|
653
|
-
{
|
|
654
|
-
|
|
655
|
-
components: { Transform: { pos: [
|
|
652
|
+
child: { components: { Transform: { pos: [0, 0, 0] }, ChildOf: { parent: 'root' } } },
|
|
653
|
+
grandchild: {
|
|
654
|
+
components: { Transform: { pos: [5, 5, 5] }, ChildOf: { parent: 'child' } },
|
|
656
655
|
},
|
|
657
|
-
|
|
658
|
-
localId: 2,
|
|
659
|
-
components: { Transform: { pos: [5, 5, 5] }, ChildOf: { parent: 1 } },
|
|
660
|
-
},
|
|
661
|
-
],
|
|
656
|
+
},
|
|
662
657
|
};
|
|
663
658
|
const ok = sceneValidate(payload);
|
|
664
659
|
expect(ok).toBe(true);
|
|
665
660
|
expect(sceneValidate.errors ?? []).toEqual([]);
|
|
666
661
|
});
|
|
662
|
+
|
|
663
|
+
it('accepts explicit skin dependency refs in the keyed payload', () => {
|
|
664
|
+
const payload = {
|
|
665
|
+
kind: 'scene',
|
|
666
|
+
skinGuids: ['skin-guid', 0],
|
|
667
|
+
entities: { root: { components: {} } },
|
|
668
|
+
};
|
|
669
|
+
expect(sceneValidate(payload)).toBe(true);
|
|
670
|
+
});
|
|
667
671
|
});
|
|
668
672
|
|
|
669
673
|
describe('SceneAsset payload schema - typo field name (AC-08(b))', () => {
|
|
670
674
|
it('rejects Transform with typo field `pozX`, ajv error mentions additional properties', () => {
|
|
671
675
|
const payload = {
|
|
672
676
|
kind: 'scene',
|
|
673
|
-
entities:
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
677
|
+
entities: {
|
|
678
|
+
root: { components: { Transform: { pos: [1, 2, 3] } } },
|
|
679
|
+
child: { components: { Transform: { pos: [0, 0, 0] } } },
|
|
680
|
+
broken: { components: { Transform: { pozX: 7, pos: [0, 0, 0] } } },
|
|
681
|
+
},
|
|
678
682
|
};
|
|
679
683
|
const ok = sceneValidate(payload);
|
|
680
684
|
expect(ok).toBe(false);
|
|
681
685
|
const errors = sceneValidate.errors ?? [];
|
|
682
686
|
expect(errors.length).toBeGreaterThan(0);
|
|
683
687
|
const e = errors[0];
|
|
684
|
-
expect(e?.instancePath ?? '').toContain('/entities/
|
|
688
|
+
expect(e?.instancePath ?? '').toContain('/entities/broken/components/Transform');
|
|
685
689
|
expect(e?.message ?? '').toContain('additional properties');
|
|
686
690
|
});
|
|
687
691
|
});
|
|
688
692
|
|
|
689
693
|
describe('SceneAsset payload schema - ChildOf.parent type guard', () => {
|
|
690
|
-
it('rejects ChildOf.parent that is not a non-
|
|
694
|
+
it('rejects ChildOf.parent that is not a non-empty entity key', () => {
|
|
691
695
|
const payload = {
|
|
692
696
|
kind: 'scene',
|
|
693
|
-
entities:
|
|
697
|
+
entities: { root: { components: { ChildOf: { parent: 3 } } } },
|
|
694
698
|
};
|
|
695
699
|
const ok = sceneValidate(payload);
|
|
696
700
|
expect(ok).toBe(false);
|
|
@@ -703,7 +707,7 @@ const V1_WHITELIST = new Set([
|
|
|
703
707
|
it('rejects a SceneEntity.components key not in the registered schema map', () => {
|
|
704
708
|
const payload = {
|
|
705
709
|
kind: 'scene',
|
|
706
|
-
entities:
|
|
710
|
+
entities: { root: { components: { MysteryComponent: { foo: 1 } } } },
|
|
707
711
|
};
|
|
708
712
|
const ok = sceneValidate(payload);
|
|
709
713
|
expect(ok).toBe(false);
|
|
@@ -713,135 +717,93 @@ const V1_WHITELIST = new Set([
|
|
|
713
717
|
expect(e?.message ?? '').toContain('additional properties');
|
|
714
718
|
});
|
|
715
719
|
});
|
|
720
|
+
|
|
721
|
+
it('rejects an empty authored entity key', () => {
|
|
722
|
+
const payload = { kind: 'scene', entities: { '': { components: {} } } };
|
|
723
|
+
expect(sceneValidate(payload)).toBe(false);
|
|
724
|
+
expect(
|
|
725
|
+
(sceneValidate.errors ?? []).some((e) => (e?.message ?? '').includes('property name')),
|
|
726
|
+
).toBe(true);
|
|
727
|
+
});
|
|
716
728
|
});
|
|
717
729
|
|
|
718
|
-
describe('
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
// `localId` / `comp` / `value` stay required; `additionalProperties: false`
|
|
725
|
-
// rejects any op-tag discriminant (OOS: no `switch (op)` fan-out downstream).
|
|
726
|
-
function makeMountPayload(override: Record<string, unknown>): Record<string, unknown> {
|
|
730
|
+
describe('scene-instance-schema.test.ts', () => {
|
|
731
|
+
type MutableInstanceEntity = {
|
|
732
|
+
instance: { overrides: Array<Record<string, unknown>> };
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
function makeInstancePayload(override: Record<string, unknown>): Record<string, unknown> {
|
|
727
736
|
return {
|
|
728
737
|
kind: 'scene',
|
|
729
|
-
entities:
|
|
730
|
-
|
|
731
|
-
{
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
738
|
+
entities: {
|
|
739
|
+
root: { components: {} },
|
|
740
|
+
child: {
|
|
741
|
+
components: {},
|
|
742
|
+
instance: {
|
|
743
|
+
source: 'child-guid',
|
|
744
|
+
overrides: [{ target: ['leaf'], components: override }],
|
|
745
|
+
},
|
|
737
746
|
},
|
|
738
|
-
|
|
747
|
+
},
|
|
739
748
|
};
|
|
740
749
|
}
|
|
741
750
|
|
|
742
|
-
it('accepts
|
|
743
|
-
const ok = sceneValidate(
|
|
744
|
-
makeMountPayload({ localId: 2, comp: 'Transform', field: 'pos', value: [1, 2, 3] }),
|
|
745
|
-
);
|
|
751
|
+
it('accepts a keyed nested instance with component fields', () => {
|
|
752
|
+
const ok = sceneValidate(makeInstancePayload({ Transform: { pos: [1, 2, 3] } }));
|
|
746
753
|
expect(ok).toBe(true);
|
|
747
754
|
expect(sceneValidate.errors ?? []).toEqual([]);
|
|
748
755
|
});
|
|
749
756
|
|
|
750
|
-
it('accepts
|
|
757
|
+
it('accepts an ordered override with multiple components', () => {
|
|
751
758
|
const ok = sceneValidate(
|
|
752
|
-
|
|
759
|
+
makeInstancePayload({
|
|
760
|
+
Transform: { pos: [1, 2, 3] },
|
|
761
|
+
MeshFilter: { mesh: 'cube' },
|
|
762
|
+
}),
|
|
753
763
|
);
|
|
754
764
|
expect(ok).toBe(true);
|
|
755
765
|
expect(sceneValidate.errors ?? []).toEqual([]);
|
|
756
766
|
});
|
|
757
767
|
|
|
758
|
-
it('rejects an override missing
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
768
|
+
it('rejects an override missing target', () => {
|
|
769
|
+
const payload = makeInstancePayload({ Transform: { pos: [1, 2, 3] } });
|
|
770
|
+
const instance = (payload.entities as Record<string, MutableInstanceEntity>).child.instance;
|
|
771
|
+
instance.overrides = [{ components: { Transform: { pos: [1, 2, 3] } } }];
|
|
772
|
+
expect(sceneValidate(payload)).toBe(false);
|
|
762
773
|
});
|
|
763
774
|
|
|
764
|
-
it('rejects an override missing
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
775
|
+
it('rejects an override missing components', () => {
|
|
776
|
+
const payload = makeInstancePayload({ Transform: { pos: [1, 2, 3] } });
|
|
777
|
+
const instance = (payload.entities as Record<string, MutableInstanceEntity>).child.instance;
|
|
778
|
+
instance.overrides = [{ target: ['leaf'] }];
|
|
779
|
+
expect(sceneValidate(payload)).toBe(false);
|
|
768
780
|
});
|
|
769
781
|
|
|
770
|
-
it('rejects an
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
782
|
+
it('rejects an instance missing source', () => {
|
|
783
|
+
const payload = makeInstancePayload({ Transform: { pos: [1, 2, 3] } });
|
|
784
|
+
const child = (payload.entities as Record<string, MutableInstanceEntity>).child;
|
|
785
|
+
child.instance = { overrides: [{ target: ['leaf'], components: {} }] };
|
|
786
|
+
expect(sceneValidate(payload)).toBe(false);
|
|
774
787
|
});
|
|
775
788
|
|
|
776
|
-
it('rejects an override carrying an op discriminant field
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
expect(
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
789
|
+
it('rejects an override carrying an op discriminant field', () => {
|
|
790
|
+
const payload = makeInstancePayload({ Transform: { pos: [1, 2, 3] } });
|
|
791
|
+
const instance = (payload.entities as Record<string, MutableInstanceEntity>).child.instance;
|
|
792
|
+
instance.overrides = [{ target: ['leaf'], components: {}, op: 'add' }];
|
|
793
|
+
expect(sceneValidate(payload)).toBe(false);
|
|
794
|
+
expect(
|
|
795
|
+
(sceneValidate.errors ?? []).some((e) =>
|
|
796
|
+
(e?.message ?? '').includes('additional properties'),
|
|
797
|
+
),
|
|
798
|
+
).toBe(true);
|
|
784
799
|
});
|
|
785
800
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
//
|
|
789
|
-
// The M5 collect fold emits component-add overrides (no `field`) whose value
|
|
790
|
-
// is a per-field map that may carry shared<T> GUID strings (e.g. the added
|
|
791
|
-
// AnimationPlayer.clips = [GUID, 0, 0, 0]). ajv's mountOverrideSchema must
|
|
792
|
-
// accept that fold-produced shape, and a serialize→validate→deserialize
|
|
793
|
-
// round-trip must not drop the override data. The override value is a free-
|
|
794
|
-
// form object (`value: {}`) at the schema layer — runtime type checks live
|
|
795
|
-
// in the ecs apply path (setSceneOverride / _validateMountOverrides).
|
|
796
|
-
// ═════════════════════════════════════════════════════════════════════════
|
|
797
|
-
|
|
798
|
-
const W22_CLIP = 'f1e2d3c4-b5a6-4b7c-8d9e-0f1a2b3c4d5e';
|
|
799
|
-
|
|
800
|
-
it('(w22-a) accepts a fold-produced add-override with a shared-field GUID array value', () => {
|
|
801
|
-
const payload = makeMountPayload({
|
|
802
|
-
localId: 2,
|
|
803
|
-
comp: 'Transform',
|
|
804
|
-
// component-add form (no field); value carries a positional array with a
|
|
805
|
-
// GUID string at slot 0 and NULL-sentinel placeholders — the exact shape
|
|
806
|
-
// the fold + serialize wiring emits for AnimationPlayer.clips.
|
|
807
|
-
value: { clips: [W22_CLIP, 0, 0, 0] },
|
|
808
|
-
});
|
|
809
|
-
const ok = sceneValidate(payload);
|
|
810
|
-
expect(ok).toBe(true);
|
|
811
|
-
expect(sceneValidate.errors ?? []).toEqual([]);
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
it('(w22-b) accepts an add-override whose value has no shared field at all', () => {
|
|
815
|
-
// component-add with a plain scalar-only value map (the minimal add shape).
|
|
816
|
-
const ok = sceneValidate(
|
|
817
|
-
makeMountPayload({ localId: 2, comp: 'Transform', value: { pos: [9, 9, 9] } }),
|
|
818
|
-
);
|
|
819
|
-
expect(ok).toBe(true);
|
|
820
|
-
expect(sceneValidate.errors ?? []).toEqual([]);
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
it('(w22-c) round-trip: JSON serialize → validate → parse preserves override data', () => {
|
|
824
|
-
const override = {
|
|
825
|
-
localId: 2,
|
|
826
|
-
comp: 'Transform',
|
|
827
|
-
value: { clips: [W22_CLIP, 0, 0, 0], pos: [1, 2, 3] },
|
|
828
|
-
};
|
|
829
|
-
const payload = makeMountPayload(override);
|
|
830
|
-
|
|
831
|
-
// Simulate the on-disk pack path: JSON.stringify → fetch → JSON.parse.
|
|
801
|
+
it('round-trips keyed instance override data through JSON', () => {
|
|
802
|
+
const payload = makeInstancePayload({ Transform: { pos: [1, 2, 3] } });
|
|
832
803
|
const roundTripped = JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
expect(
|
|
836
|
-
|
|
837
|
-
// No data lost: the override survives the round-trip byte-for-byte.
|
|
838
|
-
const mounts = roundTripped.mounts as Array<Record<string, unknown>>;
|
|
839
|
-
const overrides = mounts[0]?.overrides as Array<Record<string, unknown>>;
|
|
840
|
-
expect(overrides).toHaveLength(1);
|
|
841
|
-
expect(overrides[0]).toEqual(override);
|
|
842
|
-
const value = overrides[0]?.value as Record<string, unknown>;
|
|
843
|
-
expect(value.clips).toEqual([W22_CLIP, 0, 0, 0]);
|
|
844
|
-
expect(value.pos).toEqual([1, 2, 3]);
|
|
804
|
+
expect(sceneValidate(roundTripped)).toBe(true);
|
|
805
|
+
const child = (roundTripped.entities as Record<string, MutableInstanceEntity>).child;
|
|
806
|
+
expect(child.instance.overrides[0]?.target).toEqual(['leaf']);
|
|
845
807
|
});
|
|
846
808
|
});
|
|
847
809
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { expect, it } from 'vitest';
|
|
2
|
+
import { createRuntimePackPublication } from '../runtime-publication.js';
|
|
3
|
+
|
|
4
|
+
it('preserves declared source keys in derived publication rows and their digest', () => {
|
|
5
|
+
const input = {
|
|
6
|
+
pack: {
|
|
7
|
+
assets: [
|
|
8
|
+
{ guid: 'WALL-GUID', kind: 'volume', payload: {} },
|
|
9
|
+
{ guid: 'CONTROL-GUID', kind: 'mesh', payload: {} },
|
|
10
|
+
],
|
|
11
|
+
},
|
|
12
|
+
scopeId: 'lab',
|
|
13
|
+
sourcePath: 'wall.pack.json',
|
|
14
|
+
sourceRevision: 'source-1',
|
|
15
|
+
packageUrl: '/wall.pack.json',
|
|
16
|
+
};
|
|
17
|
+
const before = createRuntimePackPublication(input);
|
|
18
|
+
const after = createRuntimePackPublication({
|
|
19
|
+
...input,
|
|
20
|
+
sourceKeys: new Map([['wall-guid', 'wall/main']]),
|
|
21
|
+
});
|
|
22
|
+
expect(after.publication.outputs.map(({ guid, sourceKey }) => [guid, sourceKey])).toEqual([
|
|
23
|
+
['wall-guid', 'wall/main'],
|
|
24
|
+
['control-guid', 'control-guid'],
|
|
25
|
+
]);
|
|
26
|
+
expect(after.pack.digest).toBe(before.pack.digest);
|
|
27
|
+
expect(after.pack.outputSetDigest).not.toBe(before.pack.outputSetDigest);
|
|
28
|
+
expect(after.publication.receipt.outputSetDigest).toBe(after.pack.outputSetDigest);
|
|
29
|
+
// Explicit producer output rows remain authoritative when provided.
|
|
30
|
+
const explicit = createRuntimePackPublication({
|
|
31
|
+
...input,
|
|
32
|
+
sourceKeys: new Map([['wall-guid', 'ignored']]),
|
|
33
|
+
outputs: after.publication.outputs,
|
|
34
|
+
});
|
|
35
|
+
expect(explicit.publication.outputs).toEqual(after.publication.outputs);
|
|
36
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Scanner dependency-cycle coverage for keyed SceneAsset instances.
|
|
2
|
+
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import { scan } from '../scanner.js';
|
|
8
|
+
|
|
9
|
+
const GUID_A = 'aa000000-0000-4000-8000-000000000aaa';
|
|
10
|
+
const GUID_B = 'bb000000-0000-4000-8000-000000000bbb';
|
|
11
|
+
const GUID_C = 'cc000000-0000-4000-8000-000000000ccc';
|
|
12
|
+
|
|
13
|
+
function sceneAssetPack(guid: string, source?: string): unknown {
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: '1.0.0',
|
|
16
|
+
kind: 'internal-text-package',
|
|
17
|
+
assets: [
|
|
18
|
+
{
|
|
19
|
+
guid,
|
|
20
|
+
kind: 'scene',
|
|
21
|
+
refs: source === undefined ? [] : [source],
|
|
22
|
+
payload: {
|
|
23
|
+
kind: 'scene',
|
|
24
|
+
entities:
|
|
25
|
+
source === undefined
|
|
26
|
+
? { root: { components: {} } }
|
|
27
|
+
: { child: { components: {}, instance: { source: 0 } } },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('scanner keyed SceneAsset instance cycle detection', () => {
|
|
35
|
+
let dir: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
dir = await mkdtemp(join(tmpdir(), 'pack-instance-cycle-'));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await rm(dir, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('accepts an acyclic instance chain A -> B -> C', async () => {
|
|
46
|
+
await writeFile(
|
|
47
|
+
join(dir, 'a.pack.json'),
|
|
48
|
+
JSON.stringify(sceneAssetPack(GUID_A, GUID_B)),
|
|
49
|
+
'utf8',
|
|
50
|
+
);
|
|
51
|
+
await writeFile(
|
|
52
|
+
join(dir, 'b.pack.json'),
|
|
53
|
+
JSON.stringify(sceneAssetPack(GUID_B, GUID_C)),
|
|
54
|
+
'utf8',
|
|
55
|
+
);
|
|
56
|
+
await writeFile(join(dir, 'c.pack.json'), JSON.stringify(sceneAssetPack(GUID_C)), 'utf8');
|
|
57
|
+
expect((await scan([dir])).ok).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each([
|
|
61
|
+
['two', [GUID_A, GUID_B]],
|
|
62
|
+
['three', [GUID_A, GUID_B, GUID_C]],
|
|
63
|
+
])('rejects a %s-asset instance cycle', async (_name, cycle) => {
|
|
64
|
+
const [a, b, c] = cycle;
|
|
65
|
+
await writeFile(join(dir, 'a.pack.json'), JSON.stringify(sceneAssetPack(a, b)), 'utf8');
|
|
66
|
+
await writeFile(join(dir, 'b.pack.json'), JSON.stringify(sceneAssetPack(b, c ?? a)), 'utf8');
|
|
67
|
+
if (c !== undefined)
|
|
68
|
+
await writeFile(join(dir, 'c.pack.json'), JSON.stringify(sceneAssetPack(c, a)), 'utf8');
|
|
69
|
+
const result = await scan([dir]);
|
|
70
|
+
expect(result.ok).toBe(false);
|
|
71
|
+
if (result.ok) return;
|
|
72
|
+
expect(result.error.code).toBe('pack-cyclic-reference');
|
|
73
|
+
const detail = result.error.detail as { kind?: string; cycle?: readonly string[] };
|
|
74
|
+
expect(detail.kind).toBe('mount-asset');
|
|
75
|
+
const found = detail.cycle ?? [];
|
|
76
|
+
expect(found[0]).toBe(found[found.length - 1]);
|
|
77
|
+
for (const guid of cycle) expect(found).toContain(guid);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects a self-instance', async () => {
|
|
81
|
+
await writeFile(
|
|
82
|
+
join(dir, 'a.pack.json'),
|
|
83
|
+
JSON.stringify(sceneAssetPack(GUID_A, GUID_A)),
|
|
84
|
+
'utf8',
|
|
85
|
+
);
|
|
86
|
+
const result = await scan([dir]);
|
|
87
|
+
expect(result.ok).toBe(false);
|
|
88
|
+
if (result.ok) return;
|
|
89
|
+
expect(result.error.code).toBe('pack-cyclic-reference');
|
|
90
|
+
expect((result.error.detail as { kind?: string }).kind).toBe('mount-asset');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -95,7 +95,7 @@ describe('engine-pack scanner inventory contract', () => {
|
|
|
95
95
|
default: {
|
|
96
96
|
schemaVersion: '2.0.0',
|
|
97
97
|
packageId: scriptablePackageId,
|
|
98
|
-
build: () => ({ ok: true, value: { effect: { kind: 'scene', entities:
|
|
98
|
+
build: () => ({ ok: true, value: { effect: { kind: 'scene', entities: {} } } }),
|
|
99
99
|
},
|
|
100
100
|
}),
|
|
101
101
|
},
|
|
@@ -143,7 +143,7 @@ describe('engine-pack scanner inventory contract', () => {
|
|
|
143
143
|
default: {
|
|
144
144
|
schemaVersion: '2.0.0',
|
|
145
145
|
packageId,
|
|
146
|
-
build: () => ({ ok: true, value: { effect: { kind: 'scene', entities:
|
|
146
|
+
build: () => ({ ok: true, value: { effect: { kind: 'scene', entities: {} } } }),
|
|
147
147
|
},
|
|
148
148
|
}),
|
|
149
149
|
dispose: async () => {
|
|
@@ -1,12 +1,46 @@
|
|
|
1
1
|
// @perf-budget-skip: intentional ScriptablePack CLI integration gate.
|
|
2
2
|
|
|
3
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { err } from '@forgeax/engine-types';
|
|
6
7
|
import { describe, expect, it } from 'vitest';
|
|
7
8
|
import { runCliAsset } from '../cli-asset.js';
|
|
9
|
+
import { loadScriptablePack } from '../scriptable-pack-node.js';
|
|
8
10
|
|
|
9
11
|
describe('ScriptablePack CLI Meta inspection', () => {
|
|
12
|
+
it('executes TypeScript re-export closures using JavaScript specifiers and directory indexes', async () => {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-pack-ts-closure-'));
|
|
14
|
+
try {
|
|
15
|
+
await mkdir(join(root, 'nested'));
|
|
16
|
+
await writeFile(join(root, 'nested/index.ts'), 'export const count: number = 7;');
|
|
17
|
+
await writeFile(join(root, 'barrel.ts'), "export * from './nested';");
|
|
18
|
+
const source = join(root, 'shape.pack.ts');
|
|
19
|
+
await writeFile(
|
|
20
|
+
source,
|
|
21
|
+
`import { count } from './barrel.js';
|
|
22
|
+
export default { schemaVersion: '2.0.0',
|
|
23
|
+
packageId: new Uint8Array([1,159,250,151,139,57,122,210,132,204,39,50,104,89,26,180]),
|
|
24
|
+
build() { return { ok: true, value: { 'scene/main': { kind: 'scene', entities: [], mounts: [], count } } }; }
|
|
25
|
+
};`,
|
|
26
|
+
);
|
|
27
|
+
const loaded = await loadScriptablePack(source);
|
|
28
|
+
expect(loaded.ok).toBe(true);
|
|
29
|
+
if (!loaded.ok) throw loaded.error;
|
|
30
|
+
const built = await loaded.value.build({
|
|
31
|
+
packageId: loaded.value.packageId,
|
|
32
|
+
values: {},
|
|
33
|
+
readByGuid: async () => err('unused'),
|
|
34
|
+
});
|
|
35
|
+
expect(built).toMatchObject({
|
|
36
|
+
ok: true,
|
|
37
|
+
value: { 'scene/main': { kind: 'scene', count: 7 } },
|
|
38
|
+
});
|
|
39
|
+
} finally {
|
|
40
|
+
await rm(root, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
10
44
|
it('loads the definition without invoking build', async () => {
|
|
11
45
|
const root = await mkdtemp(join(tmpdir(), 'forgeax-scriptable-pack-'));
|
|
12
46
|
try {
|