@forgeax/engine-project 0.1.2
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/LICENSE +202 -0
- package/README.md +82 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/ac14-migration.test.d.ts +2 -0
- package/dist/__tests__/ac14-migration.test.d.ts.map +1 -0
- package/dist/__tests__/errors.test-d.d.ts +2 -0
- package/dist/__tests__/errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/errors.test.d.ts +2 -0
- package/dist/__tests__/errors.test.d.ts.map +1 -0
- package/dist/__tests__/loader.test.d.ts +2 -0
- package/dist/__tests__/loader.test.d.ts.map +1 -0
- package/dist/__tests__/resolve.test.d.ts +2 -0
- package/dist/__tests__/resolve.test.d.ts.map +1 -0
- package/dist/__tests__/schema.test.d.ts +2 -0
- package/dist/__tests__/schema.test.d.ts.map +1 -0
- package/dist/__tests__/structural.test.d.ts +2 -0
- package/dist/__tests__/structural.test.d.ts.map +1 -0
- package/dist/errors.d.ts +84 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +291 -0
- package/dist/index.mjs.map +1 -0
- package/dist/loader.d.ts +99 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/paths.d.ts +3 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/schema.d.ts +216 -0
- package/dist/schema.d.ts.map +1 -0
- package/package.json +65 -0
- package/src/__tests__/ac14-migration.test.ts +124 -0
- package/src/__tests__/errors.test-d.ts +77 -0
- package/src/__tests__/errors.test.ts +254 -0
- package/src/__tests__/fixtures/games/cow-level/forge.json +7 -0
- package/src/__tests__/fixtures/games/cow-survivor/forge.json +8 -0
- package/src/__tests__/fixtures/games/fps/forge.json +7 -0
- package/src/__tests__/fixtures/games/hellforge/forge.json +24 -0
- package/src/__tests__/fixtures/games/shoot-opt/forge.json +6 -0
- package/src/__tests__/fixtures/games/spin-cube/forge.json +6 -0
- package/src/__tests__/loader.test.ts +364 -0
- package/src/__tests__/resolve.test.ts +160 -0
- package/src/__tests__/schema.test.ts +317 -0
- package/src/__tests__/structural.test.ts +175 -0
- package/src/errors.ts +129 -0
- package/src/index.ts +37 -0
- package/src/loader.ts +344 -0
- package/src/paths.ts +7 -0
- package/src/schema.ts +149 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// resolve.test.ts — w5: resolveDefaultScene double-injection tests
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { resolveDefaultScene } from '../loader.js';
|
|
4
|
+
|
|
5
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
6
|
+
function makeRead(content: string): (path: string) => Promise<string> {
|
|
7
|
+
return async (_path: string) => content;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function makeReaderError(code: string, message = `reader refusal: ${code}`): Error {
|
|
11
|
+
const error = new Error(message);
|
|
12
|
+
Object.assign(error, { code });
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const VALID_WITH_SCENE = JSON.stringify({
|
|
17
|
+
id: 'test-game',
|
|
18
|
+
name: 'Test Game',
|
|
19
|
+
schemaVersion: '1.0.0',
|
|
20
|
+
defaultScene: '15acc839-d847-527c-8284-bfb36d7c50de',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const WITHOUT_SCENE = JSON.stringify({
|
|
24
|
+
id: 'no-scene',
|
|
25
|
+
name: 'No Scene',
|
|
26
|
+
schemaVersion: '1.0.0',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ── signature verification ──────────────────────────────────────────────────
|
|
30
|
+
describe('resolveDefaultScene — signature', () => {
|
|
31
|
+
it('accepts {read, resolveGuid} as both injection points', async () => {
|
|
32
|
+
const read = makeRead(VALID_WITH_SCENE);
|
|
33
|
+
const resolveGuid = async (guid: string) => ({
|
|
34
|
+
ok: true as const,
|
|
35
|
+
value: { kind: 'scene', guid },
|
|
36
|
+
});
|
|
37
|
+
const result = await resolveDefaultScene({ read, resolveGuid });
|
|
38
|
+
expect(result.ok).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── resolve success path ────────────────────────────────────────────────────
|
|
43
|
+
describe('resolveDefaultScene — success path', () => {
|
|
44
|
+
it('resolves when resolveGuid returns {ok:true} with kind=scene', async () => {
|
|
45
|
+
const read = makeRead(VALID_WITH_SCENE);
|
|
46
|
+
const resolveGuid = vi.fn(async (_guid: string) => ({
|
|
47
|
+
ok: true as const,
|
|
48
|
+
value: { kind: 'scene', guid: _guid },
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const result = await resolveDefaultScene({ read, resolveGuid });
|
|
52
|
+
expect(result.ok).toBe(true);
|
|
53
|
+
if (result.ok) {
|
|
54
|
+
expect(result.value.kind).toBe('scene');
|
|
55
|
+
expect(result.value.guid).toBe('15acc839-d847-527c-8284-bfb36d7c50de');
|
|
56
|
+
}
|
|
57
|
+
expect(resolveGuid).toHaveBeenCalledWith('15acc839-d847-527c-8284-bfb36d7c50de');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('resolveDefaultScene — reader refusal and retry', () => {
|
|
62
|
+
it('propagates a bounded read failure, then recovers with the same reader', async () => {
|
|
63
|
+
let refused = true;
|
|
64
|
+
const reads: string[] = [];
|
|
65
|
+
const read = async (path: string): Promise<string> => {
|
|
66
|
+
reads.push(path);
|
|
67
|
+
if (refused) {
|
|
68
|
+
throw makeReaderError('EAGAIN', 'secret transient reader source');
|
|
69
|
+
}
|
|
70
|
+
return VALID_WITH_SCENE;
|
|
71
|
+
};
|
|
72
|
+
const resolveGuid = vi.fn(async (guid: string) => ({
|
|
73
|
+
ok: true as const,
|
|
74
|
+
value: { kind: 'scene', guid },
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const first = await resolveDefaultScene({ read, resolveGuid });
|
|
78
|
+
expect(first).toMatchObject({
|
|
79
|
+
ok: false,
|
|
80
|
+
error: { code: 'forge-read-failed', detail: { path: 'forge.json', cause: 'transient' } },
|
|
81
|
+
});
|
|
82
|
+
expect(resolveGuid).not.toHaveBeenCalled();
|
|
83
|
+
if (!first.ok) {
|
|
84
|
+
expect(first.error.message).not.toContain('secret transient reader source');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
refused = false;
|
|
88
|
+
const second = await resolveDefaultScene({ read, resolveGuid });
|
|
89
|
+
expect(second).toEqual({
|
|
90
|
+
ok: true,
|
|
91
|
+
value: { kind: 'scene', guid: '15acc839-d847-527c-8284-bfb36d7c50de' },
|
|
92
|
+
});
|
|
93
|
+
expect(reads).toEqual(['forge.json', 'forge.json']);
|
|
94
|
+
expect(resolveGuid).toHaveBeenCalledTimes(1);
|
|
95
|
+
expect(resolveGuid).toHaveBeenCalledWith('15acc839-d847-527c-8284-bfb36d7c50de');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── resolve failure: GUID not found ─────────────────────────────────────────
|
|
100
|
+
describe('resolveDefaultScene — GUID not found', () => {
|
|
101
|
+
it('returns {ok:false} when resolveGuid returns {ok:false}', async () => {
|
|
102
|
+
const read = makeRead(VALID_WITH_SCENE);
|
|
103
|
+
const resolveGuid = async (_guid: string) => ({
|
|
104
|
+
ok: false as const,
|
|
105
|
+
error: new Error('not found'),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = await resolveDefaultScene({ read, resolveGuid });
|
|
109
|
+
expect(result.ok).toBe(false);
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
expect(result.error.code).toBe('forge-scene-unresolved');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── resolve failure: kind not scene ─────────────────────────────────────────
|
|
117
|
+
describe('resolveDefaultScene — kind mismatch', () => {
|
|
118
|
+
it('returns {ok:false} when resolved asset kind !== scene', async () => {
|
|
119
|
+
const read = makeRead(VALID_WITH_SCENE);
|
|
120
|
+
const resolveGuid = async (_guid: string) => ({
|
|
121
|
+
ok: true as const,
|
|
122
|
+
value: { kind: 'texture', guid: _guid },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await resolveDefaultScene({ read, resolveGuid });
|
|
126
|
+
expect(result.ok).toBe(false);
|
|
127
|
+
if (!result.ok) {
|
|
128
|
+
expect(result.error.code).toBe('forge-scene-unresolved');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── no defaultScene ─────────────────────────────────────────────────────────
|
|
134
|
+
describe('resolveDefaultScene — no defaultScene', () => {
|
|
135
|
+
it('returns {ok:false} when forge.json has no defaultScene', async () => {
|
|
136
|
+
const read = makeRead(WITHOUT_SCENE);
|
|
137
|
+
const resolveGuid = async (_guid: string) => ({
|
|
138
|
+
ok: true as const,
|
|
139
|
+
value: { kind: 'scene', guid: _guid },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const result = await resolveDefaultScene({ read, resolveGuid });
|
|
143
|
+
expect(result.ok).toBe(false);
|
|
144
|
+
if (!result.ok) {
|
|
145
|
+
expect(result.error.code).toBe('forge-scene-unresolved');
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── loadGameProject isolation ───────────────────────────────────────────────
|
|
151
|
+
describe('resolveDefaultScene — loadGameProject isolation', () => {
|
|
152
|
+
it('resolveDefaultScene accepts {read, resolveGuid} — loadGameProject accepts read only', () => {
|
|
153
|
+
// Type-level verification:
|
|
154
|
+
// loadGameProject: (read: (path)=>Promise<string>) => ...
|
|
155
|
+
// resolveDefaultScene: ({read, resolveGuid}) => ...
|
|
156
|
+
// The dual-injection signature separates resolve from format.
|
|
157
|
+
const _read = makeRead(VALID_WITH_SCENE);
|
|
158
|
+
expect(typeof _read).toBe('function');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// schema.test.ts — w2: GameProjectSchema + GuidString refinement tests
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { GameProjectSchema, GuidString } from '../schema.js';
|
|
4
|
+
|
|
5
|
+
// ── valid full forge.json ──────────────────────────────────────────────────
|
|
6
|
+
describe('GameProjectSchema — acceptance', () => {
|
|
7
|
+
it('accepts a valid full forge.json', () => {
|
|
8
|
+
const result = GameProjectSchema.safeParse({
|
|
9
|
+
id: 'hellforge',
|
|
10
|
+
name: 'Hellforge',
|
|
11
|
+
schemaVersion: '1.0.0',
|
|
12
|
+
defaultScene: '15acc839-d847-527c-8284-bfb36d7c50de',
|
|
13
|
+
physics: '3d',
|
|
14
|
+
pointerLock: true,
|
|
15
|
+
input: 'fps',
|
|
16
|
+
preview: {
|
|
17
|
+
skin: {
|
|
18
|
+
sceneGuid: '11111111-1111-1111-1111-111111111111',
|
|
19
|
+
clipGuids: [],
|
|
20
|
+
clipDefault: 'idle',
|
|
21
|
+
scale: 1,
|
|
22
|
+
pos: [0, 0],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
expect(result.success).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('accepts minimal forge.json with only id+name+schemaVersion', () => {
|
|
30
|
+
const result = GameProjectSchema.safeParse({
|
|
31
|
+
id: 'minimal',
|
|
32
|
+
name: 'Minimal Game',
|
|
33
|
+
schemaVersion: '1.0.0',
|
|
34
|
+
});
|
|
35
|
+
expect(result.success).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('accepts forge.json without entry (entry is optional)', () => {
|
|
39
|
+
const result = GameProjectSchema.safeParse({
|
|
40
|
+
id: 'no-entry',
|
|
41
|
+
name: 'No Entry Game',
|
|
42
|
+
schemaVersion: '1.0.0',
|
|
43
|
+
});
|
|
44
|
+
expect(result.success).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('accepts forge.json without defaultScene (defaultScene is optional)', () => {
|
|
48
|
+
const result = GameProjectSchema.safeParse({
|
|
49
|
+
id: 'no-default',
|
|
50
|
+
name: 'No Default Game',
|
|
51
|
+
schemaVersion: '1.0.0',
|
|
52
|
+
});
|
|
53
|
+
expect(result.success).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps forge.json as the engine-project manifest, not a Catalog asset', () => {
|
|
57
|
+
const result = GameProjectSchema.safeParse({
|
|
58
|
+
id: 'manifest-only',
|
|
59
|
+
name: 'Manifest Only',
|
|
60
|
+
schemaVersion: '1.0.0',
|
|
61
|
+
});
|
|
62
|
+
expect(result.success).toBe(true);
|
|
63
|
+
if (result.success) {
|
|
64
|
+
expect(result.data).not.toHaveProperty('guid');
|
|
65
|
+
expect(result.data).not.toHaveProperty('sourceKey');
|
|
66
|
+
expect(result.data).not.toHaveProperty('assets');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('accepts forge.json with entry field present', () => {
|
|
71
|
+
const result = GameProjectSchema.safeParse({
|
|
72
|
+
id: 'with-entry',
|
|
73
|
+
name: 'With Entry',
|
|
74
|
+
schemaVersion: '1.0.0',
|
|
75
|
+
entry: 'main.ts',
|
|
76
|
+
});
|
|
77
|
+
expect(result.success).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('accepts an explicit realm-local execution entry', () => {
|
|
81
|
+
const result = GameProjectSchema.safeParse({
|
|
82
|
+
id: 'worker-game',
|
|
83
|
+
name: 'Worker Game',
|
|
84
|
+
schemaVersion: '1.0.0',
|
|
85
|
+
entry: 'main.ts',
|
|
86
|
+
executionEntry: 'runtime.ts',
|
|
87
|
+
});
|
|
88
|
+
expect(result.success).toBe(true);
|
|
89
|
+
if (result.success) expect(result.data.executionEntry).toBe('runtime.ts');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('accepts a DSH-aligned plugin Entry tree with ForgeaX realm placement', () => {
|
|
93
|
+
const result = GameProjectSchema.safeParse({
|
|
94
|
+
id: 'plugin-game',
|
|
95
|
+
name: 'Plugin Game',
|
|
96
|
+
schemaVersion: '1.0.0',
|
|
97
|
+
plugins: [
|
|
98
|
+
{
|
|
99
|
+
id: 'engine-features',
|
|
100
|
+
name: 'cordis:group',
|
|
101
|
+
group: true,
|
|
102
|
+
realm: 'engine',
|
|
103
|
+
config: [
|
|
104
|
+
{
|
|
105
|
+
id: 'gameplay',
|
|
106
|
+
name: './main.ts',
|
|
107
|
+
config: { difficulty: 'hard' },
|
|
108
|
+
inject: ['world', 'renderer'],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
expect(result.success).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('rejects duplicate plugin Entry ids anywhere in one tree', () => {
|
|
118
|
+
const result = GameProjectSchema.safeParse({
|
|
119
|
+
id: 'duplicate-plugins',
|
|
120
|
+
name: 'Duplicate Plugins',
|
|
121
|
+
schemaVersion: '1.0.0',
|
|
122
|
+
plugins: [
|
|
123
|
+
{ id: 'same', name: './one.ts' },
|
|
124
|
+
{
|
|
125
|
+
id: 'group',
|
|
126
|
+
name: 'cordis:group',
|
|
127
|
+
group: true,
|
|
128
|
+
config: [{ id: 'same', name: './two.ts' }],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
expect(result.success).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── GuidString refinement ───────────────────────────────────────────────────
|
|
137
|
+
describe('GuidString refinement', () => {
|
|
138
|
+
it('accepts valid 36-char dash-form UUID v4', () => {
|
|
139
|
+
const result = GuidString.safeParse('d953a1db-483a-4b7d-8b71-b8f144488c48');
|
|
140
|
+
expect(result.success).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('accepts valid 36-char dash-form UUID v7', () => {
|
|
144
|
+
const result = GuidString.safeParse('15acc839-d847-527c-8284-bfb36d7c50de');
|
|
145
|
+
expect(result.success).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('accepts valid 36-char dash-form UUID (uppercase hex)', () => {
|
|
149
|
+
const result = GuidString.safeParse('7B4D43D4-5B19-5903-8966-F89671D21565');
|
|
150
|
+
expect(result.success).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('rejects malformed UUID string (too short)', () => {
|
|
154
|
+
const result = GuidString.safeParse('not-a-guid');
|
|
155
|
+
expect(result.success).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('rejects plain slug string (non-UUID)', () => {
|
|
159
|
+
const result = GuidString.safeParse('rogue-encampment');
|
|
160
|
+
expect(result.success).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects empty string', () => {
|
|
164
|
+
const result = GuidString.safeParse('');
|
|
165
|
+
expect(result.success).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('rejects string with only 35 chars', () => {
|
|
169
|
+
const result = GuidString.safeParse('a2345678-1234-1234-1234-12345678901');
|
|
170
|
+
expect(result.success).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── strict rejection of unknown fields ──────────────────────────────────────
|
|
175
|
+
describe('GameProjectSchema — strict rejection', () => {
|
|
176
|
+
it('rejects scenes[] as unknown field', () => {
|
|
177
|
+
const result = GameProjectSchema.safeParse({
|
|
178
|
+
id: 'has-scenes',
|
|
179
|
+
name: 'Has Scenes',
|
|
180
|
+
schemaVersion: '1.0.0',
|
|
181
|
+
scenes: [{ id: 'l1', name: 'Level 1', pack: 'scenes/level1.pack.json' }],
|
|
182
|
+
});
|
|
183
|
+
expect(result.success).toBe(false);
|
|
184
|
+
if (!result.success) {
|
|
185
|
+
// zod puts unrecognized_keys in the first issue
|
|
186
|
+
expect(result.error.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('rejects any unknown top-level field', () => {
|
|
191
|
+
const result = GameProjectSchema.safeParse({
|
|
192
|
+
id: 'extra-field',
|
|
193
|
+
name: 'Extra',
|
|
194
|
+
schemaVersion: '1.0.0',
|
|
195
|
+
unknownField: 'should not be here',
|
|
196
|
+
});
|
|
197
|
+
expect(result.success).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ── physics union (D-4: all 5 known values) ─────────────────────────────────
|
|
202
|
+
describe('GameProjectSchema — physics union', () => {
|
|
203
|
+
const physicsValues = ['3d', '2d', 'rapier-3d', 'rapier-2d', true] as const;
|
|
204
|
+
|
|
205
|
+
for (const phys of physicsValues) {
|
|
206
|
+
it(`accepts physics=${JSON.stringify(phys)}`, () => {
|
|
207
|
+
const result = GameProjectSchema.safeParse({
|
|
208
|
+
id: 'phys-test',
|
|
209
|
+
name: 'Physics Test',
|
|
210
|
+
schemaVersion: '1.0.0',
|
|
211
|
+
physics: phys,
|
|
212
|
+
});
|
|
213
|
+
expect(result.success).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it('rejects arbitrary string for physics', () => {
|
|
218
|
+
const result = GameProjectSchema.safeParse({
|
|
219
|
+
id: 'bad-physics',
|
|
220
|
+
name: 'Bad Physics',
|
|
221
|
+
schemaVersion: '1.0.0',
|
|
222
|
+
physics: 'custom-engine',
|
|
223
|
+
});
|
|
224
|
+
expect(result.success).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('rejects number for physics', () => {
|
|
228
|
+
const result = GameProjectSchema.safeParse({
|
|
229
|
+
id: 'bad-physics-num',
|
|
230
|
+
name: 'Bad Physics Num',
|
|
231
|
+
schemaVersion: '1.0.0',
|
|
232
|
+
physics: 42,
|
|
233
|
+
});
|
|
234
|
+
expect(result.success).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('accepts physics=false (boolean true/false both valid per D-4)', () => {
|
|
238
|
+
const result = GameProjectSchema.safeParse({
|
|
239
|
+
id: 'physics-false',
|
|
240
|
+
name: 'Physics False',
|
|
241
|
+
schemaVersion: '1.0.0',
|
|
242
|
+
physics: false,
|
|
243
|
+
});
|
|
244
|
+
expect(result.success).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ── pointerLock boolean ─────────────────────────────────────────────────────
|
|
249
|
+
describe('GameProjectSchema — pointerLock', () => {
|
|
250
|
+
it('accepts pointerLock=false', () => {
|
|
251
|
+
const result = GameProjectSchema.safeParse({
|
|
252
|
+
id: 'pl-false',
|
|
253
|
+
name: 'PL False',
|
|
254
|
+
schemaVersion: '1.0.0',
|
|
255
|
+
pointerLock: false,
|
|
256
|
+
});
|
|
257
|
+
expect(result.success).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('rejects non-boolean pointerLock', () => {
|
|
261
|
+
const result = GameProjectSchema.safeParse({
|
|
262
|
+
id: 'pl-string',
|
|
263
|
+
name: 'PL String',
|
|
264
|
+
schemaVersion: '1.0.0',
|
|
265
|
+
pointerLock: 'true',
|
|
266
|
+
});
|
|
267
|
+
expect(result.success).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── input string ────────────────────────────────────────────────────────────
|
|
272
|
+
describe('GameProjectSchema — input', () => {
|
|
273
|
+
it('accepts input string', () => {
|
|
274
|
+
const result = GameProjectSchema.safeParse({
|
|
275
|
+
id: 'with-input',
|
|
276
|
+
name: 'With Input',
|
|
277
|
+
schemaVersion: '1.0.0',
|
|
278
|
+
input: 'fps',
|
|
279
|
+
});
|
|
280
|
+
expect(result.success).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── preview.skin nested object ──────────────────────────────────────────────
|
|
285
|
+
describe('GameProjectSchema — preview.skin', () => {
|
|
286
|
+
it('accepts full preview.skin object', () => {
|
|
287
|
+
const result = GameProjectSchema.safeParse({
|
|
288
|
+
id: 'with-preview',
|
|
289
|
+
name: 'With Preview',
|
|
290
|
+
schemaVersion: '1.0.0',
|
|
291
|
+
preview: {
|
|
292
|
+
skin: {
|
|
293
|
+
sceneGuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
|
294
|
+
clipGuids: ['bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'],
|
|
295
|
+
clipDefault: 'walk',
|
|
296
|
+
scale: 2.5,
|
|
297
|
+
pos: [100, 200, 300],
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
expect(result.success).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('accepts minimal preview.skin (sceneGuid only)', () => {
|
|
305
|
+
const result = GameProjectSchema.safeParse({
|
|
306
|
+
id: 'preview-min',
|
|
307
|
+
name: 'Preview Min',
|
|
308
|
+
schemaVersion: '1.0.0',
|
|
309
|
+
preview: {
|
|
310
|
+
skin: {
|
|
311
|
+
sceneGuid: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
expect(result.success).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// structural.test.ts — w6: Structural verification (AC-01/02/05/06)
|
|
2
|
+
//
|
|
3
|
+
// Post-implementation verification of the built package:
|
|
4
|
+
// AC-01: package directory + package.json name
|
|
5
|
+
// AC-02: four exports accessible + GameProject is z.infer-derived
|
|
6
|
+
// AC-05: zod in dependencies, z.infer used
|
|
7
|
+
// AC-06: no node:fs / fetch imports in source
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
// ── AC-01: package directory exists + package.json name ─────────────────────
|
|
14
|
+
describe('AC-01: package directory', () => {
|
|
15
|
+
it('package directory exists at packages/engine/packages/engine-project/', () => {
|
|
16
|
+
const pkgDir = path.resolve(import.meta.dirname, '..', '..');
|
|
17
|
+
expect(fs.existsSync(pkgDir)).toBe(true);
|
|
18
|
+
|
|
19
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
20
|
+
expect(fs.existsSync(pkgJsonPath)).toBe(true);
|
|
21
|
+
|
|
22
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
23
|
+
expect(pkg.name).toBe('@forgeax/engine-project');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ── AC-02: four exports accessible ──────────────────────────────────────────
|
|
28
|
+
describe('AC-02: exports accessible', () => {
|
|
29
|
+
it('loadGameProject is importable', async () => {
|
|
30
|
+
const mod = await import('../index.js');
|
|
31
|
+
expect(mod.loadGameProject).toBeDefined();
|
|
32
|
+
expect(typeof mod.loadGameProject).toBe('function');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('GameProjectSchema is importable', async () => {
|
|
36
|
+
const mod = await import('../index.js');
|
|
37
|
+
expect(mod.GameProjectSchema).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('FORGE_JSON is importable', async () => {
|
|
41
|
+
const mod = await import('../index.js');
|
|
42
|
+
expect(mod.FORGE_JSON).toBe('forge.json');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('GameProject type is derived from z.infer', async () => {
|
|
46
|
+
// Import the schema and check that GameProject is a z.infer type.
|
|
47
|
+
// At runtime z.infer produces nothing, but we can verify the schema exists
|
|
48
|
+
// and that the type declaration compiles (verified by typecheck step).
|
|
49
|
+
const mod = await import('../index.js');
|
|
50
|
+
expect(mod.GameProjectSchema).toBeDefined();
|
|
51
|
+
// We can create a valid object and parse it using the schema
|
|
52
|
+
const result = mod.GameProjectSchema.safeParse({
|
|
53
|
+
id: 'test',
|
|
54
|
+
name: 'Test',
|
|
55
|
+
schemaVersion: '1.0.0',
|
|
56
|
+
});
|
|
57
|
+
expect(result.success).toBe(true);
|
|
58
|
+
if (result.success) {
|
|
59
|
+
// GameProject type fields are inferrable — this object has id/name/schemaVersion
|
|
60
|
+
const gp = result.data;
|
|
61
|
+
expect(gp.id).toBe('test');
|
|
62
|
+
expect(gp.name).toBe('Test');
|
|
63
|
+
expect(gp.schemaVersion).toBe('1.0.0');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('resolveDefaultScene is importable', async () => {
|
|
68
|
+
const mod = await import('../index.js');
|
|
69
|
+
expect(mod.resolveDefaultScene).toBeDefined();
|
|
70
|
+
expect(typeof mod.resolveDefaultScene).toBe('function');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('keeps the validation primitive owner-local', async () => {
|
|
74
|
+
const mod = await import('../index.js');
|
|
75
|
+
expect('validateGameProject' in mod).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('GameProjectError is importable', async () => {
|
|
79
|
+
const mod = await import('../index.js');
|
|
80
|
+
expect(mod.GameProjectError).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── AC-05: zod in dependencies, z.infer used ────────────────────────────────
|
|
85
|
+
describe('AC-05: zod dependency', () => {
|
|
86
|
+
it('package.json deps includes zod', () => {
|
|
87
|
+
const pkgDir = path.resolve(import.meta.dirname, '..', '..');
|
|
88
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
|
|
89
|
+
expect(pkg.dependencies).toBeDefined();
|
|
90
|
+
expect(pkg.dependencies.zod).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('schema.ts imports zod and uses z.infer', () => {
|
|
94
|
+
const schemaPath = path.resolve(import.meta.dirname, '..', 'schema.ts');
|
|
95
|
+
const content = fs.readFileSync(schemaPath, 'utf-8');
|
|
96
|
+
expect(content).toContain("from 'zod'");
|
|
97
|
+
expect(content).toContain('z.infer');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── AC-06: no node:fs / fetch in engine-project src/ ────────────────────────
|
|
102
|
+
describe('AC-06: no node:fs / fetch imports in source', () => {
|
|
103
|
+
const srcDir = path.resolve(import.meta.dirname, '..');
|
|
104
|
+
|
|
105
|
+
function grepForbiddenImports(filePath: string): string[] {
|
|
106
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
107
|
+
const hits: string[] = [];
|
|
108
|
+
|
|
109
|
+
// Only check actual imports, not comments
|
|
110
|
+
const lines = content.split('\n');
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
const trimmed = line.trim();
|
|
113
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
trimmed.includes("from 'node:fs'") ||
|
|
118
|
+
trimmed.includes('from "node:fs"') ||
|
|
119
|
+
trimmed.includes("from 'fs'") ||
|
|
120
|
+
trimmed.includes('from "fs"') ||
|
|
121
|
+
trimmed.includes('import fs ') ||
|
|
122
|
+
trimmed.includes('import * as fs ') ||
|
|
123
|
+
trimmed.includes("require('fs'") ||
|
|
124
|
+
trimmed.includes('require("fs"') ||
|
|
125
|
+
trimmed.includes("from 'node:path'") ||
|
|
126
|
+
trimmed.includes('from "node:path"') ||
|
|
127
|
+
trimmed.includes('globalThis.fetch') ||
|
|
128
|
+
trimmed.includes('window.fetch')
|
|
129
|
+
) {
|
|
130
|
+
hits.push(line);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Also check for fetch() calls that aren't from injection
|
|
135
|
+
const fetchCallPattern = /\bfetch\s*\(/;
|
|
136
|
+
if (fetchCallPattern.test(content)) {
|
|
137
|
+
hits.push('contains fetch() call');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return hits;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it('source files have no node:fs imports', () => {
|
|
144
|
+
const entries = fs.readdirSync(srcDir, { recursive: true });
|
|
145
|
+
const files: string[] = [];
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (
|
|
148
|
+
typeof entry === 'string' &&
|
|
149
|
+
entry.endsWith('.ts') &&
|
|
150
|
+
!entry.includes('__tests__') &&
|
|
151
|
+
!entry.includes('.test.')
|
|
152
|
+
) {
|
|
153
|
+
files.push(path.join(srcDir, entry));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
const hits = grepForbiddenImports(file);
|
|
159
|
+
expect(
|
|
160
|
+
hits,
|
|
161
|
+
`${path.relative(srcDir, file)} has forbidden imports: ${hits.join(', ')}`,
|
|
162
|
+
).toHaveLength(0);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('loader.ts uses injection-only read (no fetch/fs)', () => {
|
|
167
|
+
const loaderPath = path.resolve(import.meta.dirname, '..', 'loader.ts');
|
|
168
|
+
const content = fs.readFileSync(loaderPath, 'utf-8');
|
|
169
|
+
// loader.ts should accept (read) as parameter — no direct fs/fetch import
|
|
170
|
+
expect(content).not.toMatch(/import\s+.*from\s+['"]node:fs['"]/);
|
|
171
|
+
expect(content).not.toMatch(/import\s+.*from\s+['"]fs['"]/);
|
|
172
|
+
// The read injection signature: `read: (path: string) => Promise<string>`
|
|
173
|
+
expect(content).toContain('read: (path: string) => Promise<string>');
|
|
174
|
+
});
|
|
175
|
+
});
|