@forgeax/engine-naga 0.0.0-dev.8d955ade1c79
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 +26 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/naga.unit.test.d.ts +2 -0
- package/dist/__tests__/naga.unit.test.d.ts.map +1 -0
- package/dist/__tests__/reflect-uv-set-count.test.d.ts +2 -0
- package/dist/__tests__/reflect-uv-set-count.test.d.ts.map +1 -0
- package/dist/errors.d.ts +79 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +282 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +57 -0
- package/src/__tests__/naga.unit.test.ts +434 -0
- package/src/__tests__/reflect-uv-set-count.test.ts +219 -0
- package/src/errors.ts +208 -0
- package/src/index.ts +384 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
|
|
2
|
+
// biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
|
|
3
|
+
//
|
|
4
|
+
// Source files (N=5):
|
|
5
|
+
// - packages/naga/src/__tests__/compose.test.ts
|
|
6
|
+
// - packages/naga/src/__tests__/emit_reflection.test.ts
|
|
7
|
+
// - packages/naga/src/__tests__/errors.test.ts
|
|
8
|
+
// - packages/naga/src/__tests__/parse.test.ts
|
|
9
|
+
// - packages/naga/src/__tests__/validate.test.ts
|
|
10
|
+
//
|
|
11
|
+
// Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
|
|
12
|
+
// source as ancestorTitles[0]. Top-level imports merged + deduped.
|
|
13
|
+
//
|
|
14
|
+
// Naga packages share a common vi.mock('@forgeax/engine-wgpu-wasm') pattern.
|
|
15
|
+
// Merged into one unified mock that provides all needed functions.
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
18
|
+
import { compileFailed } from '../errors.js';
|
|
19
|
+
|
|
20
|
+
const _parse = vi.fn();
|
|
21
|
+
const _validate = vi.fn();
|
|
22
|
+
const _emit_reflection = vi.fn();
|
|
23
|
+
const _compose = vi.fn();
|
|
24
|
+
|
|
25
|
+
vi.mock('@forgeax/engine-wgpu-wasm', () => ({
|
|
26
|
+
ensureReady: vi.fn(async () => ({
|
|
27
|
+
parse: _parse,
|
|
28
|
+
validate: _validate,
|
|
29
|
+
emit_reflection: _emit_reflection,
|
|
30
|
+
compose_shader: _compose,
|
|
31
|
+
})),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
// ─── from compose.test.ts ───
|
|
36
|
+
|
|
37
|
+
describe('compose.test.ts', () => {
|
|
38
|
+
describe('@forgeax/engine-naga composeShader wrapper', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
_compose.mockReset();
|
|
41
|
+
_parse.mockReset();
|
|
42
|
+
_validate.mockReset();
|
|
43
|
+
_emit_reflection.mockReset();
|
|
44
|
+
vi.resetModules();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('forwards entry + JSON-stringified imports + defines to wasm compose_shader and returns the composed WGSL string (basic #import happy path)', async () => {
|
|
52
|
+
const entry = [
|
|
53
|
+
'#import forgeax_pbr::brdf',
|
|
54
|
+
'@vertex fn vs() -> @builtin(position) vec4<f32> { return brdf::sample(); }',
|
|
55
|
+
].join('\n');
|
|
56
|
+
const imports = {
|
|
57
|
+
'forgeax_pbr::brdf': [
|
|
58
|
+
'#define_import_path forgeax_pbr::brdf',
|
|
59
|
+
'fn sample() -> vec4<f32> { return vec4<f32>(0.0); }',
|
|
60
|
+
].join('\n'),
|
|
61
|
+
};
|
|
62
|
+
const defines = { FOO: true };
|
|
63
|
+
const composed = '// composed wgsl\nfn sample() -> vec4<f32> { return vec4<f32>(0.0); }';
|
|
64
|
+
_compose.mockReturnValueOnce(composed);
|
|
65
|
+
|
|
66
|
+
const { composeShader } = await import('../index.js');
|
|
67
|
+
const out = await composeShader(entry, imports, defines);
|
|
68
|
+
|
|
69
|
+
expect(out).toBe(composed);
|
|
70
|
+
expect(_compose).toHaveBeenCalledTimes(1);
|
|
71
|
+
const call = _compose.mock.calls[0];
|
|
72
|
+
if (!call) throw new Error('compose_shader mock was not called');
|
|
73
|
+
const [forwardedEntry, forwardedImportsJson, forwardedDefinesJson] = call;
|
|
74
|
+
expect(forwardedEntry).toBe(entry);
|
|
75
|
+
expect(JSON.parse(forwardedImportsJson)).toEqual(imports);
|
|
76
|
+
expect(JSON.parse(forwardedDefinesJson)).toEqual(defines);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('propagates shader-import-not-found: prefix error from wasm compose_shader JsError', async () => {
|
|
80
|
+
const entry =
|
|
81
|
+
'#import forgeax_missing::mod\n@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }';
|
|
82
|
+
_compose.mockImplementationOnce(() => {
|
|
83
|
+
throw new Error('shader-import-not-found: module forgeax_missing::mod not registered');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const { composeShader } = await import('../index.js');
|
|
87
|
+
await expect(composeShader(entry, {}, {})).rejects.toThrow(/shader-import-not-found:/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('passes #ifdef defines through to wasm so the upstream composer can eliminate branches (define=true branch retained; define=false branch dropped)', async () => {
|
|
91
|
+
const entry = [
|
|
92
|
+
'#ifdef WANT_FOO',
|
|
93
|
+
'fn foo() -> f32 { return 1.0; }',
|
|
94
|
+
'#endif',
|
|
95
|
+
'#ifdef WANT_BAR',
|
|
96
|
+
'fn bar() -> f32 { return 2.0; }',
|
|
97
|
+
'#endif',
|
|
98
|
+
].join('\n');
|
|
99
|
+
|
|
100
|
+
const composed = 'fn foo() -> f32 { return 1.0; }';
|
|
101
|
+
_compose.mockReturnValueOnce(composed);
|
|
102
|
+
|
|
103
|
+
const { composeShader } = await import('../index.js');
|
|
104
|
+
const out = await composeShader(entry, {}, { WANT_FOO: true, WANT_BAR: false });
|
|
105
|
+
|
|
106
|
+
expect(out).toBe(composed);
|
|
107
|
+
const call = _compose.mock.calls[0];
|
|
108
|
+
if (!call) throw new Error('compose_shader mock was not called');
|
|
109
|
+
const [, , forwardedDefinesJson] = call;
|
|
110
|
+
expect(JSON.parse(forwardedDefinesJson)).toEqual({ WANT_FOO: true, WANT_BAR: false });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
// M3 characterization: the raw Naga reader must consume only the generic
|
|
118
|
+
// shader-reflection/2 wire. Material meaning is selected by shader-compiler.
|
|
119
|
+
describe('shader-reflection/2 generic bound-global wire', () => {
|
|
120
|
+
const uniform = {
|
|
121
|
+
group: 1,
|
|
122
|
+
binding: 0,
|
|
123
|
+
addressSpace: 'uniform',
|
|
124
|
+
resourceKind: 'buffer',
|
|
125
|
+
visibility: 3,
|
|
126
|
+
name: 'surfaceParameters',
|
|
127
|
+
members: [
|
|
128
|
+
{
|
|
129
|
+
name: 'roughness',
|
|
130
|
+
type: 'f32',
|
|
131
|
+
offset: 0,
|
|
132
|
+
size: 4,
|
|
133
|
+
alignment: 4,
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
span: 16,
|
|
137
|
+
};
|
|
138
|
+
const texture = {
|
|
139
|
+
group: 1,
|
|
140
|
+
binding: 3,
|
|
141
|
+
addressSpace: 'handle',
|
|
142
|
+
resourceKind: 'texture',
|
|
143
|
+
visibility: 2,
|
|
144
|
+
name: 'albedoTexture',
|
|
145
|
+
};
|
|
146
|
+
const generic = {
|
|
147
|
+
schemaVersion: 'shader-reflection/2',
|
|
148
|
+
boundGlobals: [uniform, texture],
|
|
149
|
+
uvSetCount: 0,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
it('keeps complete coordinates, member span, visibility, kind, and diagnostic name', async () => {
|
|
153
|
+
const { parseReflectionWire } = await import('../index.js');
|
|
154
|
+
expect(parseReflectionWire(JSON.stringify(generic))).toEqual(generic);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('does not use the global name as identity when a shader renames it', async () => {
|
|
158
|
+
const { parseReflectionWire } = await import('../index.js');
|
|
159
|
+
const renamed = {
|
|
160
|
+
...generic,
|
|
161
|
+
boundGlobals: [{ ...uniform, name: 'renamedSurfaceBlock' }, texture],
|
|
162
|
+
};
|
|
163
|
+
expect(parseReflectionWire(JSON.stringify(renamed)).boundGlobals[0]).toMatchObject({
|
|
164
|
+
group: 1,
|
|
165
|
+
binding: 0,
|
|
166
|
+
addressSpace: 'uniform',
|
|
167
|
+
resourceKind: 'buffer',
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('retains unrelated groups, collisions, and binding gaps as raw facts', async () => {
|
|
172
|
+
const { parseReflectionWire } = await import('../index.js');
|
|
173
|
+
const withFacts = {
|
|
174
|
+
...generic,
|
|
175
|
+
boundGlobals: [
|
|
176
|
+
{ ...uniform, group: 0, binding: 0, name: 'engineView' },
|
|
177
|
+
{ ...uniform, group: 1, binding: 0, name: 'collision' },
|
|
178
|
+
{ ...texture, binding: 7 },
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
expect(parseReflectionWire(JSON.stringify(withFacts)).boundGlobals).toHaveLength(3);
|
|
182
|
+
expect(parseReflectionWire(JSON.stringify(withFacts)).boundGlobals[2]).toMatchObject({
|
|
183
|
+
group: 1,
|
|
184
|
+
binding: 7,
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('accepts explicit empty and static-only reflection without a material fallback', async () => {
|
|
189
|
+
const { parseReflectionWire } = await import('../index.js');
|
|
190
|
+
expect(
|
|
191
|
+
parseReflectionWire(
|
|
192
|
+
JSON.stringify({ schemaVersion: 'shader-reflection/2', boundGlobals: [], uvSetCount: 0 }),
|
|
193
|
+
),
|
|
194
|
+
).toMatchObject({ boundGlobals: [] });
|
|
195
|
+
expect(
|
|
196
|
+
parseReflectionWire(
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
schemaVersion: 'shader-reflection/2',
|
|
199
|
+
boundGlobals: [texture],
|
|
200
|
+
uvSetCount: 0,
|
|
201
|
+
}),
|
|
202
|
+
).boundGlobals,
|
|
203
|
+
).toHaveLength(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it.each([
|
|
207
|
+
['old array wire', []],
|
|
208
|
+
['missing schema version', { boundGlobals: [uniform], uvSetCount: 0 }],
|
|
209
|
+
['missing group', { ...generic, boundGlobals: [{ ...uniform, group: undefined }] }],
|
|
210
|
+
['missing binding', { ...generic, boundGlobals: [{ ...uniform, binding: undefined }] }],
|
|
211
|
+
[
|
|
212
|
+
'missing address space',
|
|
213
|
+
{ ...generic, boundGlobals: [{ ...uniform, addressSpace: undefined }] },
|
|
214
|
+
],
|
|
215
|
+
[
|
|
216
|
+
'missing resource kind',
|
|
217
|
+
{ ...generic, boundGlobals: [{ ...uniform, resourceKind: undefined }] },
|
|
218
|
+
],
|
|
219
|
+
[
|
|
220
|
+
'missing uniform members',
|
|
221
|
+
{ ...generic, boundGlobals: [{ ...uniform, members: undefined }] },
|
|
222
|
+
],
|
|
223
|
+
['missing uniform span', { ...generic, boundGlobals: [{ ...uniform, span: undefined }] }],
|
|
224
|
+
[
|
|
225
|
+
'duplicate group and binding',
|
|
226
|
+
{ ...generic, boundGlobals: [uniform, { ...uniform, name: 'duplicate' }] },
|
|
227
|
+
],
|
|
228
|
+
[
|
|
229
|
+
'legacy material fallback',
|
|
230
|
+
{
|
|
231
|
+
schemaVersion: 'shader-reflection/2',
|
|
232
|
+
boundGlobals: [],
|
|
233
|
+
uvSetCount: 0,
|
|
234
|
+
material: { members: [] },
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
])('%s fails closed', async (_label, value) => {
|
|
238
|
+
const { parseReflectionWire } = await import('../index.js');
|
|
239
|
+
expect(() => parseReflectionWire(JSON.stringify(value))).toThrow();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
{
|
|
245
|
+
// ─── from emit_reflection.test.ts ───
|
|
246
|
+
|
|
247
|
+
describe('emit_reflection.test.ts', () => {
|
|
248
|
+
describe('@forgeax/engine-naga emit_reflection wrapper', () => {
|
|
249
|
+
beforeEach(() => {
|
|
250
|
+
_parse.mockReset();
|
|
251
|
+
_validate.mockReset();
|
|
252
|
+
_emit_reflection.mockReset();
|
|
253
|
+
vi.resetModules();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
afterEach(() => {
|
|
257
|
+
vi.clearAllMocks();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('returns Result.ok(reflectionJson) and forwards (validated, options_json) verbatim', async () => {
|
|
261
|
+
const validatedHandle = { _tag: 'ValidatedModule' };
|
|
262
|
+
const expectedJson = '[{"label":"@group(0)","entries":[]}]';
|
|
263
|
+
_emit_reflection.mockReturnValueOnce(expectedJson);
|
|
264
|
+
const optionsJson = JSON.stringify({ dynamicOffsets: [{ group: 0, binding: 0 }] });
|
|
265
|
+
const { emit_reflection } = await import('../index.js');
|
|
266
|
+
const r = await emit_reflection(validatedHandle, optionsJson);
|
|
267
|
+
expect(r.ok).toBe(true);
|
|
268
|
+
if (r.ok) {
|
|
269
|
+
expect(r.value).toBe(expectedJson);
|
|
270
|
+
}
|
|
271
|
+
expect(_emit_reflection).toHaveBeenCalledWith(validatedHandle, optionsJson);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('returns Result.err(ShaderError) with non-empty hint when raw emit_reflection throws', async () => {
|
|
275
|
+
_emit_reflection.mockImplementationOnce(() => {
|
|
276
|
+
throw new Error('reflection serialize failed: cyclic type graph');
|
|
277
|
+
});
|
|
278
|
+
const { emit_reflection } = await import('../index.js');
|
|
279
|
+
const r = await emit_reflection({ _tag: 'ValidatedModule' }, '{}');
|
|
280
|
+
expect(r.ok).toBe(false);
|
|
281
|
+
if (!r.ok) {
|
|
282
|
+
expect(r.error.code).toBe('shader-compile-failed');
|
|
283
|
+
expect(r.error.message).toContain('cyclic type graph');
|
|
284
|
+
expect(r.error.hint).toBeTruthy();
|
|
285
|
+
expect(r.error.hint.length).toBeGreaterThan(0);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
{
|
|
293
|
+
// ─── from errors.test.ts ───
|
|
294
|
+
|
|
295
|
+
describe('errors.test.ts', () => {
|
|
296
|
+
describe('compileFailed factory — D-PS-3 path (b) contract', () => {
|
|
297
|
+
it('compileFailed without compilerMessages keeps detail undefined', () => {
|
|
298
|
+
const err = compileFailed({
|
|
299
|
+
message: 'WGSL parse failed at line 3, column 5',
|
|
300
|
+
hint: 'fix the WGSL source at the indicated line/column',
|
|
301
|
+
});
|
|
302
|
+
expect(err.code).toBe('shader-compile-failed');
|
|
303
|
+
expect(err.detail).toBeUndefined();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('compileFailed with compilerMessages constructs typed detail (regression smoke)', () => {
|
|
307
|
+
const err = compileFailed({
|
|
308
|
+
message: 'WGSL parse failed',
|
|
309
|
+
hint: 'see ShaderError.detail.compilerMessages',
|
|
310
|
+
compilerMessages: [],
|
|
311
|
+
});
|
|
312
|
+
expect(err.code).toBe('shader-compile-failed');
|
|
313
|
+
expect(err.detail).toBeDefined();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
{
|
|
320
|
+
// ─── from parse.test.ts ───
|
|
321
|
+
|
|
322
|
+
describe('parse.test.ts', () => {
|
|
323
|
+
describe('@forgeax/engine-naga parse wrapper', () => {
|
|
324
|
+
beforeEach(() => {
|
|
325
|
+
_parse.mockReset();
|
|
326
|
+
_validate.mockReset();
|
|
327
|
+
_emit_reflection.mockReset();
|
|
328
|
+
vi.resetModules();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
afterEach(() => {
|
|
332
|
+
vi.clearAllMocks();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('returns Result.ok(opaque handle) when raw wasm parse succeeds', async () => {
|
|
336
|
+
const opaqueHandle = { _tag: 'ParsedModule' };
|
|
337
|
+
_parse.mockReturnValueOnce(opaqueHandle);
|
|
338
|
+
const { parse } = await import('../index.js');
|
|
339
|
+
const r = await parse(
|
|
340
|
+
'@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }',
|
|
341
|
+
);
|
|
342
|
+
expect(r.ok).toBe(true);
|
|
343
|
+
if (r.ok) {
|
|
344
|
+
expect(r.value).toBe(opaqueHandle);
|
|
345
|
+
}
|
|
346
|
+
expect(_parse).toHaveBeenCalledTimes(1);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('returns Result.err(ShaderError code=shader-compile-failed) with lineNum/linePos when raw parse throws JsError with ParseErrorPayload', async () => {
|
|
350
|
+
const payload = {
|
|
351
|
+
message: "expected ';', found '@'",
|
|
352
|
+
summary: "expected ';', found '@' at line 3 col 12",
|
|
353
|
+
line_num: 3,
|
|
354
|
+
line_pos: 12,
|
|
355
|
+
};
|
|
356
|
+
_parse.mockImplementationOnce(() => {
|
|
357
|
+
throw new Error(JSON.stringify(payload));
|
|
358
|
+
});
|
|
359
|
+
const { parse } = await import('../index.js');
|
|
360
|
+
const r = await parse('invalid wgsl');
|
|
361
|
+
expect(r.ok).toBe(false);
|
|
362
|
+
if (!r.ok) {
|
|
363
|
+
expect(r.error.code).toBe('shader-compile-failed');
|
|
364
|
+
expect(r.error.lineNum).toBe(3);
|
|
365
|
+
expect(r.error.linePos).toBe(12);
|
|
366
|
+
expect(r.error.message).toContain('line 3');
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('hint is non-empty for every error path (charter proposition 3)', async () => {
|
|
371
|
+
const payload = { message: 'parse failed', line_num: 1, line_pos: 1 };
|
|
372
|
+
_parse.mockImplementationOnce(() => {
|
|
373
|
+
throw new Error(JSON.stringify(payload));
|
|
374
|
+
});
|
|
375
|
+
const { parse } = await import('../index.js');
|
|
376
|
+
const r = await parse('invalid');
|
|
377
|
+
expect(r.ok).toBe(false);
|
|
378
|
+
if (!r.ok) {
|
|
379
|
+
expect(r.error.hint).toBeTruthy();
|
|
380
|
+
expect(r.error.hint.length).toBeGreaterThan(0);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
{
|
|
388
|
+
// ─── from validate.test.ts ───
|
|
389
|
+
|
|
390
|
+
describe('validate.test.ts', () => {
|
|
391
|
+
describe('@forgeax/engine-naga validate wrapper', () => {
|
|
392
|
+
beforeEach(() => {
|
|
393
|
+
_parse.mockReset();
|
|
394
|
+
_validate.mockReset();
|
|
395
|
+
_emit_reflection.mockReset();
|
|
396
|
+
vi.resetModules();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
afterEach(() => {
|
|
400
|
+
vi.clearAllMocks();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('returns Result.ok(opaque handle) when raw wasm validate succeeds', async () => {
|
|
404
|
+
const parsedHandle = { _tag: 'ParsedModule' };
|
|
405
|
+
const validatedHandle = { _tag: 'ValidatedModule' };
|
|
406
|
+
_validate.mockReturnValueOnce(validatedHandle);
|
|
407
|
+
const { validate } = await import('../index.js');
|
|
408
|
+
const r = await validate(parsedHandle);
|
|
409
|
+
expect(r.ok).toBe(true);
|
|
410
|
+
if (r.ok) {
|
|
411
|
+
expect(r.value).toBe(validatedHandle);
|
|
412
|
+
}
|
|
413
|
+
expect(_validate).toHaveBeenCalledTimes(1);
|
|
414
|
+
expect(_validate).toHaveBeenCalledWith(parsedHandle);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('returns Result.err(ShaderError) with prose fallback when validator throws non-JSON message', async () => {
|
|
418
|
+
_validate.mockImplementationOnce(() => {
|
|
419
|
+
throw new Error('validate failed: type mismatch in @location(0)');
|
|
420
|
+
});
|
|
421
|
+
const { validate } = await import('../index.js');
|
|
422
|
+
const r = await validate({ _tag: 'ParsedModule' });
|
|
423
|
+
expect(r.ok).toBe(false);
|
|
424
|
+
if (!r.ok) {
|
|
425
|
+
expect(r.error.code).toBe('shader-compile-failed');
|
|
426
|
+
expect(r.error.message).toContain('type mismatch');
|
|
427
|
+
expect(r.error.lineNum).toBeUndefined();
|
|
428
|
+
expect(r.error.linePos).toBeUndefined();
|
|
429
|
+
expect(r.error.hint).toBeTruthy();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// feat-20260629-multi-uv-set-support m4-w1: naga reflection uvSetCount unit test.
|
|
2
|
+
//
|
|
3
|
+
// Tests that naga emit_reflection returns uvSetCount derived from vertex
|
|
4
|
+
// @location declarations (D-4 convention: uv0 at location 2, uv1..7 at
|
|
5
|
+
// location 6..12, counting only vec2<f32> vertex input arguments).
|
|
6
|
+
//
|
|
7
|
+
// This test calls through the real @forgeax/engine-naga TS wrapper (parse
|
|
8
|
+
// -> validate -> emit_reflection), not mocked wasm. It starts RED because
|
|
9
|
+
// the current emit_reflection (pre m4-w2) does not include uvSetCount in
|
|
10
|
+
// the reflection JSON output. After m4-w2 (naga.rs modification) + m4-w4
|
|
11
|
+
// (wasm rebuild), this test should go GREEN.
|
|
12
|
+
//
|
|
13
|
+
// Wgsl snippets avoid trailing commas inside struct definitions (WGSL
|
|
14
|
+
// spec permits them but being conservative avoids naga parser edge cases).
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
import { emit_reflection, parse, validate } from '../index.js';
|
|
18
|
+
|
|
19
|
+
// --- wgsl test fixtures --------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const WGSL_UV0_ONLY = `\
|
|
22
|
+
struct VsIn {
|
|
23
|
+
@location(0) pos: vec3<f32>,
|
|
24
|
+
@location(1) normal: vec3<f32>,
|
|
25
|
+
@location(2) uv: vec2<f32>,
|
|
26
|
+
@location(3) tangent: vec4<f32>
|
|
27
|
+
};
|
|
28
|
+
@vertex fn vs(in: VsIn) -> @builtin(position) vec4<f32> {
|
|
29
|
+
return vec4<f32>(0.0);
|
|
30
|
+
}
|
|
31
|
+
@fragment fn fs() -> @location(0) vec4<f32> {
|
|
32
|
+
return vec4<f32>(0.0);
|
|
33
|
+
}`;
|
|
34
|
+
|
|
35
|
+
const WGSL_UV0_UV1 = `\
|
|
36
|
+
struct VsIn {
|
|
37
|
+
@location(0) pos: vec3<f32>,
|
|
38
|
+
@location(1) normal: vec3<f32>,
|
|
39
|
+
@location(2) uv: vec2<f32>,
|
|
40
|
+
@location(3) tangent: vec4<f32>,
|
|
41
|
+
@location(6) uv1: vec2<f32>
|
|
42
|
+
};
|
|
43
|
+
@vertex fn vs(in: VsIn) -> @builtin(position) vec4<f32> {
|
|
44
|
+
return vec4<f32>(0.0);
|
|
45
|
+
}
|
|
46
|
+
@fragment fn fs() -> @location(0) vec4<f32> {
|
|
47
|
+
return vec4<f32>(0.0);
|
|
48
|
+
}`;
|
|
49
|
+
|
|
50
|
+
const WGSL_UV0_UV1_UV2 = `\
|
|
51
|
+
struct VsIn {
|
|
52
|
+
@location(0) pos: vec3<f32>,
|
|
53
|
+
@location(1) normal: vec3<f32>,
|
|
54
|
+
@location(2) uv: vec2<f32>,
|
|
55
|
+
@location(3) tangent: vec4<f32>,
|
|
56
|
+
@location(6) uv1: vec2<f32>,
|
|
57
|
+
@location(7) uv2: vec2<f32>
|
|
58
|
+
};
|
|
59
|
+
@vertex fn vs(in: VsIn) -> @builtin(position) vec4<f32> {
|
|
60
|
+
return vec4<f32>(0.0);
|
|
61
|
+
}
|
|
62
|
+
@fragment fn fs() -> @location(0) vec4<f32> {
|
|
63
|
+
return vec4<f32>(0.0);
|
|
64
|
+
}`;
|
|
65
|
+
|
|
66
|
+
const WGSL_UV0_SKIP_UV7 = `\
|
|
67
|
+
struct VsIn {
|
|
68
|
+
@location(0) pos: vec3<f32>,
|
|
69
|
+
@location(1) normal: vec3<f32>,
|
|
70
|
+
@location(2) uv: vec2<f32>,
|
|
71
|
+
@location(3) tangent: vec4<f32>,
|
|
72
|
+
@location(8) uv3: vec2<f32>
|
|
73
|
+
};
|
|
74
|
+
@vertex fn vs(in: VsIn) -> @builtin(position) vec4<f32> {
|
|
75
|
+
return vec4<f32>(0.0);
|
|
76
|
+
}
|
|
77
|
+
@fragment fn fs() -> @location(0) vec4<f32> {
|
|
78
|
+
return vec4<f32>(0.0);
|
|
79
|
+
}`;
|
|
80
|
+
|
|
81
|
+
// Also test a skin-aware WGSL with 0 extra UV
|
|
82
|
+
const WGSL_SKIN_UV0_ONLY = `\
|
|
83
|
+
struct VsIn {
|
|
84
|
+
@location(0) pos: vec3<f32>,
|
|
85
|
+
@location(1) normal: vec3<f32>,
|
|
86
|
+
@location(2) uv: vec2<f32>,
|
|
87
|
+
@location(3) tangent: vec4<f32>,
|
|
88
|
+
@location(4) skinIndex: vec4<u32>,
|
|
89
|
+
@location(5) skinWeight: vec4<f32>
|
|
90
|
+
};
|
|
91
|
+
@vertex fn vs(in: VsIn) -> @builtin(position) vec4<f32> {
|
|
92
|
+
return vec4<f32>(0.0);
|
|
93
|
+
}
|
|
94
|
+
@fragment fn fs() -> @location(0) vec4<f32> {
|
|
95
|
+
return vec4<f32>(0.0);
|
|
96
|
+
}`;
|
|
97
|
+
|
|
98
|
+
// --- test body -----------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe('reflect-uv-set-count.test.ts', () => {
|
|
101
|
+
interface ReflectionOutput {
|
|
102
|
+
bindings: unknown;
|
|
103
|
+
uvSetCount: number;
|
|
104
|
+
schemaVersion: 'shader-reflection/2';
|
|
105
|
+
boundGlobals: readonly {
|
|
106
|
+
group: number;
|
|
107
|
+
binding: number;
|
|
108
|
+
addressSpace: string;
|
|
109
|
+
resourceKind: string;
|
|
110
|
+
name?: string;
|
|
111
|
+
members?: readonly {
|
|
112
|
+
name: string;
|
|
113
|
+
type: string;
|
|
114
|
+
offset: number;
|
|
115
|
+
size: number;
|
|
116
|
+
alignment: number;
|
|
117
|
+
}[];
|
|
118
|
+
span?: number;
|
|
119
|
+
}[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function reflectWgsl(wgsl: string): Promise<ReflectionOutput> {
|
|
123
|
+
const parsed = await parse(wgsl);
|
|
124
|
+
if (!parsed.ok) throw new Error(`parse failed: ${parsed.error.message}`);
|
|
125
|
+
const validated = await validate(parsed.value);
|
|
126
|
+
if (!validated.ok) throw new Error(`validate failed: ${validated.error.message}`);
|
|
127
|
+
const reflection = await emit_reflection(validated.value, '{}');
|
|
128
|
+
if (!reflection.ok) throw new Error(`emit_reflection failed: ${reflection.error.message}`);
|
|
129
|
+
const parsedObj = JSON.parse(reflection.value);
|
|
130
|
+
// After m4-w2 the reflection JSON format is { bindings: [...], uvSetCount: N }.
|
|
131
|
+
if (Array.isArray(parsedObj)) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
'Reflection output is still the old array format; m4-w2 naga.rs changes not yet applied or wasm not rebuilt.',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return parsedObj as ReflectionOutput;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe('naga emit_reflection uvSetCount derivation (D-4: uv0@loc2, extra@loc6+)', () => {
|
|
140
|
+
it('uv0 only -> uvSetCount=1', async () => {
|
|
141
|
+
const r = await reflectWgsl(WGSL_UV0_ONLY);
|
|
142
|
+
expect(r.uvSetCount).toBe(1);
|
|
143
|
+
expect(Array.isArray(r.bindings)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('uv0 + uv1 (locations 2 and 6) -> uvSetCount=2', async () => {
|
|
147
|
+
const r = await reflectWgsl(WGSL_UV0_UV1);
|
|
148
|
+
expect(r.uvSetCount).toBe(2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('uv0 + uv1 + uv2 (locations 2, 6, 7) -> uvSetCount=3', async () => {
|
|
152
|
+
const r = await reflectWgsl(WGSL_UV0_UV1_UV2);
|
|
153
|
+
expect(r.uvSetCount).toBe(3);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('uv0 + skip to uv3 at location 8 -> uvSetCount=4 (max(location>=6)-5=3 extra)', async () => {
|
|
157
|
+
const r = await reflectWgsl(WGSL_UV0_SKIP_UV7);
|
|
158
|
+
// Per D-4 jump convention: max(location>=6) = 8, so uvSetCount = 1 + (8-5) = 4.
|
|
159
|
+
// The shader declares uv3 at @location(8) which implies uv1(loc6) and
|
|
160
|
+
// uv2(loc7) also exist in the packing convention even if not physically
|
|
161
|
+
// present in VsIn — clamp-to-last handles the gap.
|
|
162
|
+
expect(r.uvSetCount).toBe(4);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('skin shader with uv0 only -> uvSetCount=1 (skinIndex/skinWeight not counted as UV)', async () => {
|
|
166
|
+
const r = await reflectWgsl(WGSL_SKIN_UV0_ONLY);
|
|
167
|
+
expect(r.uvSetCount).toBe(1);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('generic shader-reflection/2 bound globals', () => {
|
|
172
|
+
const WGSL_GENERIC_GLOBALS = `\
|
|
173
|
+
struct MaterialBlock {
|
|
174
|
+
tint: vec4<f32>,
|
|
175
|
+
roughness: f32
|
|
176
|
+
};
|
|
177
|
+
@group(0) @binding(0) var<uniform> renamedView: vec4<f32>;
|
|
178
|
+
@group(1) @binding(0) var<storage, read> unrelatedStorage: array<u32>;
|
|
179
|
+
@group(2) @binding(0) var<uniform> renamedMaterial: MaterialBlock;
|
|
180
|
+
@group(2) @binding(2) var materialTexture: texture_2d<f32>;
|
|
181
|
+
@group(2) @binding(5) var materialSampler: sampler;
|
|
182
|
+
@vertex fn vs() -> @builtin(position) vec4<f32> {
|
|
183
|
+
return renamedView + renamedMaterial.tint;
|
|
184
|
+
}
|
|
185
|
+
@fragment fn fs() -> @location(0) vec4<f32> {
|
|
186
|
+
return textureSample(materialTexture, materialSampler, vec2<f32>(0.0));
|
|
187
|
+
}`;
|
|
188
|
+
|
|
189
|
+
it('emits every bound global with generic facts, including binding gaps', async () => {
|
|
190
|
+
const r = await reflectWgsl(WGSL_GENERIC_GLOBALS);
|
|
191
|
+
expect(r.schemaVersion).toBe('shader-reflection/2');
|
|
192
|
+
expect(r.boundGlobals).toEqual(
|
|
193
|
+
expect.arrayContaining([
|
|
194
|
+
expect.objectContaining({ group: 0, binding: 0 }),
|
|
195
|
+
expect.objectContaining({ group: 1, binding: 0 }),
|
|
196
|
+
expect.objectContaining({ group: 2, binding: 0, addressSpace: 'uniform' }),
|
|
197
|
+
expect.objectContaining({ group: 2, binding: 2, resourceKind: 'texture' }),
|
|
198
|
+
expect.objectContaining({ group: 2, binding: 5, resourceKind: 'sampler' }),
|
|
199
|
+
]),
|
|
200
|
+
);
|
|
201
|
+
expect(r.boundGlobals).toHaveLength(5);
|
|
202
|
+
const material = r.boundGlobals.find((global) => global.group === 2 && global.binding === 0);
|
|
203
|
+
expect(material?.members).toEqual([
|
|
204
|
+
expect.objectContaining({ name: 'tint', type: 'vec4<f32>' }),
|
|
205
|
+
expect.objectContaining({ name: 'roughness', type: 'f32' }),
|
|
206
|
+
]);
|
|
207
|
+
expect(material?.span).toBeGreaterThan(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('does not identify the material by global name', async () => {
|
|
211
|
+
const renamed = WGSL_GENERIC_GLOBALS.replaceAll('renamedMaterial', 'surfaceBlock');
|
|
212
|
+
const original = await reflectWgsl(WGSL_GENERIC_GLOBALS);
|
|
213
|
+
const changed = await reflectWgsl(renamed);
|
|
214
|
+
expect(changed.boundGlobals.map(({ name: _name, ...global }) => global)).toEqual(
|
|
215
|
+
original.boundGlobals.map(({ name: _name, ...global }) => global),
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|