@forgeax/engine-rhi-webgpu 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.
Files changed (33) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +133 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/__mocks__/gpu-device.d.ts +226 -0
  5. package/dist/__tests__/__mocks__/gpu-device.d.ts.map +1 -0
  6. package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts +2 -0
  7. package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts.map +1 -0
  8. package/dist/__tests__/rhi-webgpu.unit.test.d.ts +2 -0
  9. package/dist/__tests__/rhi-webgpu.unit.test.d.ts.map +1 -0
  10. package/dist/device.d.ts +62 -0
  11. package/dist/device.d.ts.map +1 -0
  12. package/dist/errors.d.ts +49 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/index.d.ts +183 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.mjs +1745 -0
  17. package/dist/index.mjs.map +1 -0
  18. package/dist/internal/__tests__/timestamp-query.unit.test.d.ts +2 -0
  19. package/dist/internal/__tests__/timestamp-query.unit.test.d.ts.map +1 -0
  20. package/dist/internal/error-translation.d.ts +16 -0
  21. package/dist/internal/error-translation.d.ts.map +1 -0
  22. package/dist/internal/timestamp-query.d.ts +15 -0
  23. package/dist/internal/timestamp-query.d.ts.map +1 -0
  24. package/package.json +58 -0
  25. package/src/__tests__/__mocks__/gpu-device.ts +555 -0
  26. package/src/__tests__/dawn-real-gpu.dawn.test.ts +1445 -0
  27. package/src/__tests__/rhi-webgpu.unit.test.ts +2398 -0
  28. package/src/device.ts +2102 -0
  29. package/src/errors.ts +183 -0
  30. package/src/index.ts +597 -0
  31. package/src/internal/__tests__/timestamp-query.unit.test.ts +88 -0
  32. package/src/internal/error-translation.ts +187 -0
  33. package/src/internal/timestamp-query.ts +59 -0
@@ -0,0 +1,2398 @@
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=8):
5
+ // - packages/rhi-webgpu/src/__tests__/capabilities.test.ts
6
+ // - packages/rhi-webgpu/src/__tests__/command-encoder.test.ts
7
+ // - packages/rhi-webgpu/src/__tests__/descriptors.test.ts
8
+ // - packages/rhi-webgpu/src/__tests__/error-scope-removed.test.ts
9
+ // - packages/rhi-webgpu/src/__tests__/errors.test.ts
10
+ // - packages/rhi-webgpu/src/__tests__/queue-real-path.test.ts
11
+ // - packages/rhi-webgpu/src/__tests__/render-pass-encoder.test.ts
12
+ // - packages/rhi-webgpu/src/__tests__/rhi-caps-probe.test.ts
13
+ //
14
+ // Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
15
+ // source as ancestorTitles[0]. Top-level imports merged + deduped.
16
+
17
+ import type {
18
+ CanvasConfiguration,
19
+ RenderPipelineDescriptor,
20
+ RhiCanvasContext,
21
+ } from '@forgeax/engine-rhi';
22
+ import { type Result, RhiError, type RhiErrorCode } from '@forgeax/engine-rhi';
23
+ import { afterEach, describe, expect, it, vi } from 'vitest';
24
+ import { makeRhiDevice } from '../device';
25
+ import { acquireCanvasContext, createShaderModule, requestAdapter, requestDevice } from '../index';
26
+ import { createMockGpu, type MockCapture, makeShaderError } from './__mocks__/gpu-device';
27
+
28
+ {
29
+ // --- from capabilities.test.ts ---
30
+ // MVP-1.2 runtime — Capabilities exposed as three independent layers + runtime
31
+ // non-null assertions.
32
+ //
33
+ // TDD red → green: this file is red at the t16 commit (t17 has not yet
34
+ // implemented the readonly device.caps / features / limits fields) → turns
35
+ // green after the t17 commit.
36
+ //
37
+ // Three independent semantic layers (charter proposition 5 / plan-strategy §7.2):
38
+ // - device.caps — hardware-probe layer (readonly boolean flags)
39
+ // - device.features — enabled-features layer (ReadonlySet<GPUFeatureName>)
40
+ // - device.limits — numeric-limit layer (readonly GPUSupportedLimits)
41
+ //
42
+ // Anchors: requirements §AC MVP-1.2 + §hard constraint 6 + §AI User Affordances /
43
+ // error self-rescue; plan-strategy §4.3 key-test-point table row 3 +
44
+ // §7.2 naming convention + §7.4 discoverability
45
+ // 'device.caps three readonly layers exposed via IDE type reflection';
46
+ // research §F-1 (GPUSupportedLimits 35 items) + §F-7 (wgpu-hal
47
+ // Capabilities three layers).
48
+
49
+ describe('MVP-1.2 runtime — caps / features / limits exposed as three independent layers + non-null', () => {
50
+ it('device.caps is a readonly object with the boolean flag fields declared in this loop', async () => {
51
+ const gpu = createMockGpu();
52
+ const r = await requestDevice({ gpu });
53
+ if (!r.ok) throw new Error('mock requestDevice failed');
54
+ const device = r.value;
55
+
56
+ expect(typeof device.caps).toBe('object');
57
+ expect(device.caps).not.toBeNull();
58
+ // RhiCaps 7 fields: compute / timestampQuery / indirectDrawing /
59
+ // textureCompression / multiDrawIndirect / pushConstants /
60
+ // textureBindingArray.
61
+ expect(typeof device.caps.compute).toBe('boolean');
62
+ expect(typeof device.caps.timestampQuery).toBe('boolean');
63
+ expect(typeof device.caps.indirectDrawing).toBe('boolean');
64
+ expect(typeof device.caps.textureCompressionBc).toBe('boolean');
65
+ expect(typeof device.caps.textureCompressionEtc2).toBe('boolean');
66
+ expect(typeof device.caps.textureCompressionAstc).toBe('boolean');
67
+ });
68
+
69
+ it('M2 w10 — device.caps.backendKind === "webgpu" (backend self-report)', async () => {
70
+ const gpu = createMockGpu();
71
+ const r = await requestDevice({ gpu });
72
+ if (!r.ok) throw new Error('mock requestDevice failed');
73
+ const device = r.value;
74
+
75
+ expect(device.caps.backendKind).toBe('webgpu');
76
+ });
77
+
78
+ it('device.features is a ReadonlySet<GPUFeatureName>, size is readable (non-null set interface object)', async () => {
79
+ const gpu = createMockGpu();
80
+ const r = await requestDevice({ gpu });
81
+ if (!r.ok) throw new Error('mock requestDevice failed');
82
+ const device = r.value;
83
+
84
+ expect(typeof device.features).toBe('object');
85
+ expect(device.features).not.toBeNull();
86
+ expect(typeof device.features.size).toBe('number');
87
+ // The mock's default features set is empty; the interface object itself is
88
+ // still non-null (the size field is readable).
89
+ expect(device.features.size).toBeGreaterThanOrEqual(0);
90
+ // ReadonlySet shape: the `has` method is present.
91
+ expect(typeof device.features.has).toBe('function');
92
+ });
93
+
94
+ it('device.limits is a readonly object containing the key numeric fields of GPUSupportedLimits', async () => {
95
+ const gpu = createMockGpu();
96
+ const r = await requestDevice({ gpu });
97
+ if (!r.ok) throw new Error('mock requestDevice failed');
98
+ const device = r.value;
99
+
100
+ expect(typeof device.limits).toBe('object');
101
+ expect(device.limits).not.toBeNull();
102
+ // Any numeric field is non-undefined (the mock's default-value table covers
103
+ // 30 items including maxBindGroups / maxBufferSize / etc.).
104
+ expect(typeof device.limits.maxBindGroups).toBe('number');
105
+ expect(device.limits.maxBindGroups).toBeGreaterThan(0);
106
+ expect(typeof device.limits.maxTextureDimension2D).toBe('number');
107
+ expect(device.limits.maxTextureDimension2D).toBeGreaterThan(0);
108
+ });
109
+
110
+ it('caps / features / limits are three independent references (not aliases / no shared identity)', async () => {
111
+ const gpu = createMockGpu();
112
+ const r = await requestDevice({ gpu });
113
+ if (!r.ok) throw new Error('mock requestDevice failed');
114
+ const device = r.value;
115
+
116
+ // The three fields must be three independent object references (charter
117
+ // proposition 5 / plan-strategy §7.4).
118
+ expect(device.caps).not.toBe(device.features as unknown);
119
+ expect(device.caps).not.toBe(device.limits as unknown);
120
+ expect(device.features as unknown).not.toBe(device.limits as unknown);
121
+ });
122
+
123
+ it('AI-user capability-probe path example — if (device.caps.compute) ... compiles without errors', async () => {
124
+ const gpu = createMockGpu();
125
+ const r = await requestDevice({ gpu });
126
+ if (!r.ok) throw new Error('mock requestDevice failed');
127
+ const device = r.value;
128
+
129
+ // This branch is primarily a type test — proposition 4 explicit signal:
130
+ // reading caps.X as a boolean directly never throws. The runtime side
131
+ // effect is only branch selection; it must not error.
132
+ let took = 'none';
133
+ if (device.caps.compute) {
134
+ took = 'compute';
135
+ } else {
136
+ took = 'no-compute';
137
+ }
138
+ expect(['compute', 'no-compute']).toContain(took);
139
+ });
140
+ });
141
+ }
142
+
143
+ {
144
+ // --- from command-encoder.test.ts ---
145
+ // w2 unit - RhiCommandEncoder shim behaviour around finish() lifecycle.
146
+ //
147
+ // RED at w2 commit (createCommandEncoder + 9 spec methods + lifecycle wrap not
148
+ // yet implemented); turns GREEN after w3 lands the impl.
149
+ //
150
+ // Asserts:
151
+ // 1) `device.createCommandEncoder(desc?)` returns Result.ok with a real
152
+ // RhiCommandEncoder handle.
153
+ // 2) `encoder.finish()` returns Result.ok<CommandBuffer> on the first call.
154
+ // 3) Calling `encoder.beginRenderPass(...)` after `finish()` returns
155
+ // Result.err({ code: 'command-encoder-finished' }) per D-S3.
156
+ // 4) Calling `encoder.finish()` again returns Result.err with the same code.
157
+ //
158
+ // Charter mapping: proposition 4 (explicit failure: lifecycle violation maps
159
+ // to a structured error rather than throwing).
160
+ //
161
+ // Note: this test exercises the shim against the mock GPU device, so the
162
+ // "real" GPUCommandEncoder lifecycle is simulated via the mock encoder
163
+ // fixture in __mocks__/gpu-device.ts. dawn.node real-GPU coverage of this
164
+ // scenario is in w17 (M5 integration).
165
+
166
+ describe('w2 - RhiCommandEncoder lifecycle (red until w3)', () => {
167
+ it('device.createCommandEncoder(desc?) returns Result.ok<RhiCommandEncoder>', async () => {
168
+ const gpu = createMockGpu();
169
+ const r = await requestDevice({ gpu });
170
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
171
+ const device = r.value as unknown as {
172
+ createCommandEncoder?: (desc?: { label?: string | undefined } | undefined) => {
173
+ ok: boolean;
174
+ value?: unknown;
175
+ error?: { code: string };
176
+ };
177
+ };
178
+ expect(typeof device.createCommandEncoder).toBe('function');
179
+ const encResult = device.createCommandEncoder?.({ label: 'frame' });
180
+ expect(encResult).toBeDefined();
181
+ expect(encResult?.ok).toBe(true);
182
+ });
183
+
184
+ it('encoder.finish() yields Result.ok<CommandBuffer>; subsequent beginRenderPass yields command-encoder-finished', async () => {
185
+ const gpu = createMockGpu();
186
+ const r = await requestDevice({ gpu });
187
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
188
+ const device = r.value as unknown as {
189
+ createCommandEncoder?: (desc?: unknown) => {
190
+ ok: boolean;
191
+ value?: {
192
+ finish: () => { ok: boolean; value?: unknown; error?: { code: string } };
193
+ beginRenderPass: (desc: unknown) => unknown;
194
+ };
195
+ error?: { code: string };
196
+ };
197
+ };
198
+ const encResult = device.createCommandEncoder?.();
199
+ if (!encResult?.ok || encResult.value === undefined) {
200
+ throw new Error('createCommandEncoder should succeed in mock');
201
+ }
202
+ const encoder = encResult.value;
203
+
204
+ const finishResult = encoder.finish();
205
+ expect(finishResult.ok).toBe(true);
206
+
207
+ // Second call to finish() should return command-encoder-finished.
208
+ const finishAgain = encoder.finish();
209
+ expect(finishAgain.ok).toBe(false);
210
+ if (!finishAgain.ok && finishAgain.error !== undefined) {
211
+ expect(finishAgain.error.code).toBe('command-encoder-finished');
212
+ }
213
+ });
214
+ });
215
+
216
+ // w24 - resolveQuerySet placeholder retirement red phase. Asserts:
217
+ // (a) destinationOffset % 256 != 0 -> webgpu-runtime-error with .expected
218
+ // literal 'destinationOffset % 256 == 0 (spec normative)'.
219
+ // (b) destination.usage missing QUERY_RESOLVE -> webgpu-runtime-error with
220
+ // .expected literal 'destination.usage must contain QUERY_RESOLVE'.
221
+ // (c) firstQuery / firstQuery + queryCount range bounds.
222
+ //
223
+ // F-3 ai-user-review absorption: literal grep on .expected string contents
224
+ // (charter proposition 4 explicit failure: K-2 merges all alignment / usage /
225
+ // bounds violations under webgpu-runtime-error; .expected must distinguish).
226
+ //
227
+ // Anchors: requirements §IN-3 / §AC-03 / §AC-12; research §2.3 +
228
+ // §7.2 + §9; plan-strategy §2 K-2 + §6 M3 + K-10.
229
+
230
+ const QUERY_RESOLVE_USAGE = 0x200;
231
+ const COPY_DST_USAGE = 0x08;
232
+
233
+ describe('w24 - resolveQuerySet destinationOffset alignment maps to webgpu-runtime-error (K-2 / spec normative)', () => {
234
+ it('destinationOffset = 8 returns webgpu-runtime-error with .expected literal (F-3)', async () => {
235
+ const gpu = createMockGpu();
236
+ const r = await requestDevice({ gpu });
237
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
238
+ const device = r.value;
239
+
240
+ const qsResult = device.createQuerySet({ type: 'occlusion', count: 4 });
241
+ if (!qsResult.ok) throw new Error('mock createQuerySet failed');
242
+ const dstResult = device.createBuffer({
243
+ label: 'mock-resolve-dst',
244
+ size: 256,
245
+ usage: QUERY_RESOLVE_USAGE,
246
+ });
247
+ if (!dstResult.ok) throw new Error('mock createBuffer failed');
248
+
249
+ const encResult = device.createCommandEncoder({ label: 'mock-resolve-align' });
250
+ if (!encResult.ok) throw new Error('mock createCommandEncoder failed');
251
+
252
+ const out = encResult.value.resolveQuerySet(qsResult.value, 0, 4, dstResult.value, 8);
253
+ expect(out.ok).toBe(false);
254
+ if (!out.ok) {
255
+ expect(out.error.code).toBe('webgpu-runtime-error');
256
+ // F-3 literal expected assertion (ai-user-review).
257
+ expect(out.error.expected).toBe('destinationOffset % 256 == 0 (spec normative)');
258
+ }
259
+ });
260
+ });
261
+
262
+ describe('w24 - resolveQuerySet destination.usage missing QUERY_RESOLVE maps to webgpu-runtime-error', () => {
263
+ it('destination usage = COPY_DST without QUERY_RESOLVE returns webgpu-runtime-error with .expected literal (F-3)', async () => {
264
+ const gpu = createMockGpu();
265
+ const r = await requestDevice({ gpu });
266
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
267
+ const device = r.value;
268
+
269
+ const qsResult = device.createQuerySet({ type: 'occlusion', count: 4 });
270
+ if (!qsResult.ok) throw new Error('mock createQuerySet failed');
271
+ const dstResult = device.createBuffer({
272
+ label: 'mock-resolve-dst-no-qr',
273
+ size: 256,
274
+ usage: COPY_DST_USAGE,
275
+ });
276
+ if (!dstResult.ok) throw new Error('mock createBuffer failed');
277
+
278
+ const encResult = device.createCommandEncoder({ label: 'mock-resolve-no-qr' });
279
+ if (!encResult.ok) throw new Error('mock createCommandEncoder failed');
280
+
281
+ const out = encResult.value.resolveQuerySet(qsResult.value, 0, 4, dstResult.value, 0);
282
+ expect(out.ok).toBe(false);
283
+ if (!out.ok) {
284
+ expect(out.error.code).toBe('webgpu-runtime-error');
285
+ // F-3 literal expected assertion (ai-user-review).
286
+ expect(out.error.expected).toBe('destination.usage must contain QUERY_RESOLVE');
287
+ }
288
+ });
289
+ });
290
+
291
+ describe('w24 - resolveQuerySet firstQuery + queryCount range bounds', () => {
292
+ it('firstQuery + queryCount > querySet.count returns webgpu-runtime-error with .expected literal (F-3)', async () => {
293
+ const gpu = createMockGpu();
294
+ const r = await requestDevice({ gpu });
295
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
296
+ const device = r.value;
297
+
298
+ const qsResult = device.createQuerySet({ type: 'occlusion', count: 4 });
299
+ if (!qsResult.ok) throw new Error('mock createQuerySet failed');
300
+ const dstResult = device.createBuffer({
301
+ label: 'mock-resolve-dst-oob',
302
+ size: 256,
303
+ usage: QUERY_RESOLVE_USAGE,
304
+ });
305
+ if (!dstResult.ok) throw new Error('mock createBuffer failed');
306
+
307
+ const encResult = device.createCommandEncoder({ label: 'mock-resolve-oob' });
308
+ if (!encResult.ok) throw new Error('mock createCommandEncoder failed');
309
+
310
+ const out = encResult.value.resolveQuerySet(qsResult.value, 2, 3, dstResult.value, 0);
311
+ expect(out.ok).toBe(false);
312
+ if (!out.ok) {
313
+ expect(out.error.code).toBe('webgpu-runtime-error');
314
+ // F-3 literal expected assertion (ai-user-review).
315
+ expect(out.error.expected).toBe('firstQuery + queryCount <= querySet.count');
316
+ }
317
+ });
318
+ });
319
+
320
+ describe('w2 timestamp resolve and readback resource contract', () => {
321
+ it('resolves aligned timestamp queries, reads u64 slots, and releases resources', async () => {
322
+ const gpu = createMockGpu();
323
+ const adapter = await gpu.requestAdapter();
324
+ if (adapter === null) throw new Error('mock adapter should exist');
325
+ const raw = await adapter.requestDevice();
326
+ const features = raw.features as unknown as Set<GPUFeatureName>;
327
+ features.add('timestamp-query');
328
+
329
+ let resolveCalls = 0;
330
+ const originalCreateCommandEncoder = raw.createCommandEncoder.bind(raw);
331
+ raw.createCommandEncoder = (descriptor) => {
332
+ const encoder = originalCreateCommandEncoder(descriptor);
333
+ const originalResolveQuerySet = encoder.resolveQuerySet.bind(encoder);
334
+ encoder.resolveQuerySet = (...args) => {
335
+ resolveCalls += 1;
336
+ originalResolveQuerySet(...args);
337
+ };
338
+ return encoder;
339
+ };
340
+
341
+ const { device } = makeRhiDevice(raw as unknown as GPUDevice);
342
+ const querySetResult = device.createQuerySet({ type: 'timestamp', count: 2 });
343
+ expect(querySetResult.ok).toBe(true);
344
+ if (!querySetResult.ok) return;
345
+
346
+ const readbackResult = device.createBuffer({
347
+ label: 'w2-timestamp-readback',
348
+ size: 256,
349
+ usage: QUERY_RESOLVE_USAGE | 0x1,
350
+ });
351
+ expect(readbackResult.ok).toBe(true);
352
+ if (!readbackResult.ok) return;
353
+
354
+ const encoderResult = device.createCommandEncoder({ label: 'w2-timestamp-resolve' });
355
+ expect(encoderResult.ok).toBe(true);
356
+ if (!encoderResult.ok) return;
357
+
358
+ const resolveResult = encoderResult.value.resolveQuerySet(
359
+ querySetResult.value,
360
+ 0,
361
+ 2,
362
+ readbackResult.value,
363
+ 0,
364
+ );
365
+ expect(resolveResult.ok).toBe(true);
366
+ expect(resolveCalls).toBe(1);
367
+
368
+ const mappedResult = await readbackResult.value.mapAsync(0x1);
369
+ expect(mappedResult.ok).toBe(true);
370
+ if (!mappedResult.ok) return;
371
+ const mappedRange = mappedResult.value.getMappedRange(0, 16);
372
+ expect(mappedRange.ok).toBe(true);
373
+ if (!mappedRange.ok) return;
374
+ expect(new BigUint64Array(mappedRange.value).length).toBe(2);
375
+ mappedResult.value.unmap();
376
+
377
+ expect(device.destroyQuerySet(querySetResult.value).ok).toBe(true);
378
+ expect(device.destroyBuffer(readbackResult.value).ok).toBe(true);
379
+ });
380
+ });
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // w38 (M5 / K-3) - RhiCommandEncoder.writeTimestamp gating + happy path.
384
+ // ---------------------------------------------------------------------------
385
+ //
386
+ // research §2.4: dawn TimestampOnCommandEncoder calls
387
+ // encoder.WriteTimestamp(querySet, queryIndex) directly on the command
388
+ // encoder; the entry is gated on the 'timestamp-query' device feature.
389
+ // The forgeax form is RhiCommandEncoder.writeTimestamp(querySet, queryIndex)
390
+ // with `void` return (spec literal alignment); when caps.timestampQuery is
391
+ // false the shim fans out 'feature-not-enabled' through the engine onError
392
+ // channel (no Result wrapper because the spec method returns void).
393
+
394
+ describe('w38 (M5 / K-3) - RhiCommandEncoder.writeTimestamp', () => {
395
+ it('writeTimestamp(querySet, queryIndex) is callable on a CommandEncoder when caps.timestampQuery is true', async () => {
396
+ const gpu = createMockGpu();
397
+ const r = await requestDevice({ gpu });
398
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
399
+ const device = r.value as unknown as {
400
+ caps: { timestampQuery: boolean };
401
+ createQuerySet: (desc: { type: string; count: number; label?: string }) => {
402
+ ok: boolean;
403
+ value: unknown;
404
+ };
405
+ createCommandEncoder: (desc?: unknown) => {
406
+ ok: boolean;
407
+ value: { writeTimestamp?: (qs: unknown, idx: number) => void };
408
+ };
409
+ };
410
+ // Mock device defaults to timestampQuery=false; the gate test below
411
+ // covers that path. Here we assert the surface exists at the very least.
412
+ const encResult = device.createCommandEncoder({ label: 'w38-encoder' });
413
+ expect(encResult.ok).toBe(true);
414
+ expect(typeof encResult.value.writeTimestamp).toBe('function');
415
+ });
416
+
417
+ it('maps an opaque QuerySet in compute-pass timestampWrites to the raw descriptor', async () => {
418
+ const gpu = createMockGpu();
419
+ const adapter = await gpu.requestAdapter();
420
+ if (adapter === null) throw new Error('mock adapter should exist');
421
+ const raw = await adapter.requestDevice();
422
+ (raw.features as unknown as Set<GPUFeatureName>).add('timestamp-query');
423
+ let rawQuerySet: unknown;
424
+ const originalCreateQuerySet = raw.createQuerySet.bind(raw);
425
+ raw.createQuerySet = (descriptor) => {
426
+ const result = originalCreateQuerySet(descriptor);
427
+ rawQuerySet = result;
428
+ return result;
429
+ };
430
+ let captured: GPUComputePassDescriptor | undefined;
431
+ const originalCreateCommandEncoder = raw.createCommandEncoder.bind(raw);
432
+ raw.createCommandEncoder = (descriptor) => {
433
+ const encoder = originalCreateCommandEncoder(descriptor) as unknown as Record<
434
+ string,
435
+ unknown
436
+ >;
437
+ const originalBegin = encoder.beginComputePass as (
438
+ passDescriptor?: GPUComputePassDescriptor,
439
+ ) => unknown;
440
+ encoder.beginComputePass = (passDescriptor?: GPUComputePassDescriptor) => {
441
+ captured = passDescriptor;
442
+ return originalBegin.call(encoder, passDescriptor);
443
+ };
444
+ return encoder as unknown as ReturnType<typeof raw.createCommandEncoder>;
445
+ };
446
+ const { device } = makeRhiDevice(raw as unknown as GPUDevice);
447
+ const querySet = device.createQuerySet({ type: 'timestamp', count: 2 });
448
+ expect(querySet.ok).toBe(true);
449
+ if (!querySet.ok) return;
450
+ const encoder = device.createCommandEncoder();
451
+ expect(encoder.ok).toBe(true);
452
+ if (!encoder.ok) return;
453
+ const pass = encoder.value.beginComputePass({
454
+ label: 'hdrp-cluster-membership',
455
+ timestampWrites: {
456
+ querySet: querySet.value,
457
+ beginningOfPassWriteIndex: 0,
458
+ endOfPassWriteIndex: 1,
459
+ },
460
+ });
461
+ pass.end();
462
+ expect(captured).toMatchObject({
463
+ label: 'hdrp-cluster-membership',
464
+ timestampWrites: {
465
+ querySet: querySet.value,
466
+ beginningOfPassWriteIndex: 0,
467
+ endOfPassWriteIndex: 1,
468
+ },
469
+ });
470
+ expect(rawQuerySet).toBeDefined();
471
+ expect(captured?.timestampWrites?.querySet).toBe(rawQuerySet);
472
+ });
473
+
474
+ async function timestampEncoder(
475
+ writeTimestamp: ((querySet: unknown, queryIndex: number) => void) | undefined,
476
+ ) {
477
+ const gpu = createMockGpu();
478
+ const adapter = await gpu.requestAdapter();
479
+ if (adapter === null) throw new Error('mock adapter should exist');
480
+ const raw = await adapter.requestDevice();
481
+ const features = raw.features as unknown as Set<GPUFeatureName>;
482
+ features.add('timestamp-query');
483
+ const originalCreateCommandEncoder = raw.createCommandEncoder.bind(raw);
484
+ raw.createCommandEncoder = (descriptor) => {
485
+ const encoder = originalCreateCommandEncoder(descriptor) as unknown as Record<
486
+ string,
487
+ unknown
488
+ >;
489
+ if (writeTimestamp !== undefined) encoder.writeTimestamp = writeTimestamp;
490
+ return encoder as unknown as ReturnType<typeof raw.createCommandEncoder>;
491
+ };
492
+ const { device } = makeRhiDevice(raw as unknown as GPUDevice);
493
+ const querySet = device.createQuerySet({ type: 'timestamp', count: 2 });
494
+ if (!querySet.ok) throw new Error('timestamp query set should be created');
495
+ const encoder = device.createCommandEncoder();
496
+ if (!encoder.ok) throw new Error('command encoder should be created');
497
+ return { encoder: encoder.value, querySet: querySet.value };
498
+ }
499
+
500
+ it('forwards a callable raw writeTimestamp exactly once with the raw query set and index', async () => {
501
+ const calls: Array<{ querySet: unknown; queryIndex: number }> = [];
502
+ const { encoder, querySet } = await timestampEncoder((rawQuerySet, queryIndex) => {
503
+ calls.push({ querySet: rawQuerySet, queryIndex });
504
+ });
505
+ encoder.writeTimestamp(querySet, 1);
506
+ expect(calls).toHaveLength(1);
507
+ expect(calls[0]?.queryIndex).toBe(1);
508
+ expect(calls[0]?.querySet).toBe(querySet);
509
+ });
510
+
511
+ it('throws structured webgpu-runtime-error when a timestamp-capable raw encoder omits writeTimestamp', async () => {
512
+ const { encoder, querySet } = await timestampEncoder(undefined);
513
+ expect(() => encoder.writeTimestamp(querySet, 0)).toThrow(RhiError);
514
+ try {
515
+ encoder.writeTimestamp(querySet, 0);
516
+ } catch (error) {
517
+ expect(error).toMatchObject({
518
+ code: 'webgpu-runtime-error',
519
+ expected: 'underlying GPUCommandEncoder.writeTimestamp to be callable',
520
+ });
521
+ expect((error as RhiError).hint).toContain('timestamp-query');
522
+ }
523
+ });
524
+
525
+ it('throws structured webgpu-runtime-error when the raw timestamp write throws', async () => {
526
+ const { encoder, querySet } = await timestampEncoder(() => {
527
+ throw new Error('raw timestamp failure');
528
+ });
529
+ expect(() => encoder.writeTimestamp(querySet, 0)).toThrow(RhiError);
530
+ try {
531
+ encoder.writeTimestamp(querySet, 0);
532
+ } catch (error) {
533
+ expect(error).toMatchObject({
534
+ code: 'webgpu-runtime-error',
535
+ expected: 'underlying GPUCommandEncoder.writeTimestamp to succeed',
536
+ });
537
+ expect((error as RhiError).hint).toContain('raw timestamp failure');
538
+ }
539
+ });
540
+ });
541
+ }
542
+
543
+ {
544
+ // --- from descriptors.test.ts ---
545
+ // MVP-1.1 runtime + AC-05 — 5 descriptor creation paths: pass-through assertions
546
+ // + `?: T | undefined` guard assertions.
547
+ //
548
+ // TDD red → green: this file is red at the t14 commit (t17 has not yet
549
+ // implemented requestDevice + the 5 descriptor shims in
550
+ // rhi-webgpu/src/index.ts) → turns green after the t17 commit.
551
+ //
552
+ // Strategy: inject `createMockGpu()` through the `gpu?: GPU` provider seam,
553
+ // invoke the shim's `requestDevice` / `device.createX`, and assert:
554
+ // 1) Each of the 5 descriptor shapes passes its input fields through to
555
+ // `mock.__captured[].descriptor` verbatim.
556
+ // 2) `?: T | undefined` guard: across the two calling shapes — passing
557
+ // `{ x: undefined }` vs not passing `x` at all — the shim's
558
+ // `'x' in src` guard makes the latter **omit `x`** when forwarding to the
559
+ // mock and the former **explicitly forward `undefined`**.
560
+ //
561
+ // Anchors: requirements §AC MVP-1.1 + AC-05 + §hard constraint 10 + edge cases;
562
+ // plan-strategy §2 S-3 (the 5-descriptor list) + S-7 (`?: T | undefined`
563
+ // + `'x' in src`) + §4.3 key-test-point table row 1 runtime;
564
+ // research §F-1 + §F-3.
565
+
566
+ // GPUTextureUsage bitmask constants (W3C WebGPU §texture). The DOM globals
567
+ // are not bound in node + dawn-only environments, so the spec values are
568
+ // re-declared here for unit-test code paths that do not import dawn.
569
+ const COPY_SRC = 0x01;
570
+ const TEXTURE_BINDING = 0x04;
571
+ const STORAGE_BINDING = 0x08;
572
+
573
+ /** Take the last captured event of a given kind (tolerates leading requestAdapter / requestDevice noise). */
574
+ function lastOf<K extends MockCapture['kind']>(
575
+ captured: readonly MockCapture[],
576
+ kind: K,
577
+ ): Extract<MockCapture, { kind: K }> {
578
+ for (let i = captured.length - 1; i >= 0; i -= 1) {
579
+ const c = captured[i];
580
+ if (c !== undefined && c.kind === kind) {
581
+ return c as Extract<MockCapture, { kind: K }>;
582
+ }
583
+ }
584
+ throw new Error(`mock did not capture an event with kind=${kind}`);
585
+ }
586
+
587
+ describe('MVP-1.1 runtime — verbatim pass-through of the 5 descriptors', () => {
588
+ it('BufferDescriptor passes through size / usage / label / mappedAtCreation', async () => {
589
+ const gpu = createMockGpu();
590
+ const r = await requestDevice({ gpu });
591
+ expect(r.ok).toBe(true);
592
+ if (!r.ok) return;
593
+ const device = r.value;
594
+
595
+ const out = device.createBuffer({
596
+ label: 'vbo',
597
+ size: 1024,
598
+ usage: 0x20,
599
+ mappedAtCreation: true,
600
+ });
601
+ expect(out.ok).toBe(true);
602
+
603
+ const ev = lastOf(gpu.__captured, 'createBuffer');
604
+ expect(ev.descriptor.label).toBe('vbo');
605
+ expect(ev.descriptor.size).toBe(1024);
606
+ expect(ev.descriptor.usage).toBe(0x20);
607
+ expect(ev.descriptor.mappedAtCreation).toBe(true);
608
+ });
609
+
610
+ it('TextureDescriptor passes through size / format / usage / textureBindingViewDimension (R8 follow-up)', async () => {
611
+ const gpu = createMockGpu();
612
+ const r = await requestDevice({ gpu });
613
+ if (!r.ok) throw new Error('mock requestDevice failed');
614
+ const device = r.value;
615
+
616
+ const out = device.createTexture({
617
+ label: 'tex',
618
+ size: [128, 128],
619
+ format: 'rgba8unorm',
620
+ usage: 0x4,
621
+ textureBindingViewDimension: '2d',
622
+ });
623
+ expect(out.ok).toBe(true);
624
+
625
+ const ev = lastOf(gpu.__captured, 'createTexture');
626
+ expect(ev.descriptor.format).toBe('rgba8unorm');
627
+ expect(ev.descriptor.size).toEqual([128, 128]);
628
+ expect(ev.descriptor.usage).toBe(0x4);
629
+ expect(ev.descriptor.textureBindingViewDimension).toBe('2d');
630
+ });
631
+
632
+ it('SamplerDescriptor passes through magFilter / minFilter / compare / maxAnisotropy', async () => {
633
+ const gpu = createMockGpu();
634
+ const r = await requestDevice({ gpu });
635
+ if (!r.ok) throw new Error('mock requestDevice failed');
636
+ const device = r.value;
637
+
638
+ const out = device.createSampler({
639
+ magFilter: 'linear',
640
+ minFilter: 'nearest',
641
+ compare: 'less',
642
+ maxAnisotropy: 4,
643
+ });
644
+ expect(out.ok).toBe(true);
645
+
646
+ const ev = lastOf(gpu.__captured, 'createSampler');
647
+ expect(ev.descriptor?.magFilter).toBe('linear');
648
+ expect(ev.descriptor?.minFilter).toBe('nearest');
649
+ expect(ev.descriptor?.compare).toBe('less');
650
+ expect(ev.descriptor?.maxAnisotropy).toBe(4);
651
+ });
652
+
653
+ it('BindGroupLayoutDescriptor passes through the entries array reference', async () => {
654
+ const gpu = createMockGpu();
655
+ const r = await requestDevice({ gpu });
656
+ if (!r.ok) throw new Error('mock requestDevice failed');
657
+ const device = r.value;
658
+
659
+ const entries: GPUBindGroupLayoutEntry[] = [
660
+ { binding: 0, visibility: 0x1, buffer: { type: 'uniform' } },
661
+ ];
662
+ const out = device.createBindGroupLayout({ label: 'bgl', entries });
663
+ expect(out.ok).toBe(true);
664
+
665
+ const ev = lastOf(gpu.__captured, 'createBindGroupLayout');
666
+ expect(ev.descriptor.label).toBe('bgl');
667
+ expect(ev.descriptor.entries).toEqual(entries);
668
+ });
669
+
670
+ it('RenderPipelineDescriptor passes through vertex / fragment / layout', async () => {
671
+ const gpu = createMockGpu();
672
+ const r = await requestDevice({ gpu });
673
+ if (!r.ok) throw new Error('mock requestDevice failed');
674
+ const device = r.value;
675
+
676
+ // fix-f3: shader creation goes through the top-level async
677
+ // `createShaderModule` entry (the RhiDevice interface no longer holds the
678
+ // synchronous createShaderModule placeholder).
679
+ const shader = await createShaderModule(device, {
680
+ code: '@vertex fn v() -> @builtin(position) vec4f { return vec4f(0); }',
681
+ });
682
+ if (!shader.ok) throw new Error('mock shader creation failed');
683
+ // The forgeax RenderPipelineDescriptor uses the `?: T | undefined` shape
684
+ // (S-7) — explicitly passing `fragment: undefined` is allowed.
685
+ const desc: RenderPipelineDescriptor = {
686
+ label: 'rpp',
687
+ layout: 'auto',
688
+ vertex: { module: shader.value, entryPoint: 'v', buffers: [] },
689
+ fragment: undefined,
690
+ };
691
+ const out = device.createRenderPipeline(desc);
692
+ expect(out.ok).toBe(true);
693
+
694
+ const ev = lastOf(gpu.__captured, 'createRenderPipeline');
695
+ expect(ev.descriptor.label).toBe('rpp');
696
+ expect(ev.descriptor.layout).toBe('auto');
697
+ expect(ev.descriptor.vertex.entryPoint).toBe('v');
698
+ });
699
+
700
+ it('converts a synchronous render-pipeline failure into a structured Result.err', async () => {
701
+ const gpu = createMockGpu({ renderPipelineError: 'pipeline validation failed' });
702
+ const r = await requestDevice({ gpu });
703
+ if (!r.ok) throw new Error('mock requestDevice failed');
704
+ const device = r.value;
705
+
706
+ const shader = await createShaderModule(device, {
707
+ code: '@vertex fn v() -> @builtin(position) vec4f { return vec4f(0); }',
708
+ });
709
+ if (!shader.ok) throw new Error('mock shader creation failed');
710
+ const out = device.createRenderPipeline({
711
+ label: 'invalid-rpp',
712
+ layout: 'auto',
713
+ vertex: {
714
+ module: shader.value,
715
+ entryPoint: 'v',
716
+ buffers: [],
717
+ },
718
+ fragment: undefined,
719
+ });
720
+
721
+ expect(out.ok).toBe(false);
722
+ if (out.ok) return;
723
+ expect(out.error.code).toBe('webgpu-runtime-error');
724
+ expect(out.error.expected).toBe('underlying GPUDevice.createRenderPipeline to succeed');
725
+ expect(out.error.hint).toContain('pipeline validation failed');
726
+ });
727
+ });
728
+
729
+ describe('S-7 + hard constraint 10 — `?: T | undefined` guard (omitted vs explicit undefined)', () => {
730
+ it('when label is omitted, the descriptor passed to the mock does not contain the label key (the `"x" in src` guard fires)', async () => {
731
+ const gpu = createMockGpu();
732
+ const r = await requestDevice({ gpu });
733
+ if (!r.ok) throw new Error('mock requestDevice failed');
734
+ const device = r.value;
735
+
736
+ device.createBuffer({ size: 16, usage: 0 });
737
+ const ev = lastOf(gpu.__captured, 'createBuffer');
738
+ expect('label' in ev.descriptor).toBe(false);
739
+ });
740
+
741
+ it('when label: undefined is explicitly passed, the descriptor passed to the mock contains the label key with value undefined', async () => {
742
+ const gpu = createMockGpu();
743
+ const r = await requestDevice({ gpu });
744
+ if (!r.ok) throw new Error('mock requestDevice failed');
745
+ const device = r.value;
746
+
747
+ device.createBuffer({ size: 16, usage: 0, label: undefined });
748
+ const ev = lastOf(gpu.__captured, 'createBuffer');
749
+ expect('label' in ev.descriptor).toBe(true);
750
+ expect(ev.descriptor.label).toBeUndefined();
751
+ });
752
+ });
753
+
754
+ // w04 — createTextureView vitest unit mock red phase. TDD red asserts:
755
+ // (a) verbatim pass-through of the 9 TextureViewDescriptor fields (label
756
+ // optional + 8 spec fields).
757
+ // (b) `'x' in src` guard distinguishes missing vs explicit-undefined when
758
+ // forwarding to the mock.
759
+ // (c) cross-resource validation: format ∉ source.format ∪ source.viewFormats
760
+ // returns Result.err({ code: 'webgpu-runtime-error' }) (research §1.1
761
+ // cross-resource gate; charter proposition 4 explicit failure).
762
+ // (d) cross-resource validation: usage not a subset of source.usage returns
763
+ // Result.err({ code: 'webgpu-runtime-error' }).
764
+ // Anchors: requirements §IN-1 / §AC-01 / §AC-07(b) / boundary case row 3;
765
+ // research §1.1 + §9 error code mapping; plan-strategy §4.2 + K-10.
766
+ describe('w04 createTextureView - verbatim pass-through of 9 TextureViewDescriptor fields', () => {
767
+ it('passes through label / format / dimension / usage / aspect / mip / array layer fields', async () => {
768
+ const gpu = createMockGpu();
769
+ const r = await requestDevice({ gpu });
770
+ if (!r.ok) throw new Error('mock requestDevice failed');
771
+ const device = r.value;
772
+
773
+ const texOut = device.createTexture({
774
+ label: 'tex-source',
775
+ size: [128, 128],
776
+ format: 'rgba8unorm',
777
+ usage: TEXTURE_BINDING | COPY_SRC,
778
+ viewFormats: ['rgba8unorm-srgb'],
779
+ });
780
+ if (!texOut.ok) throw new Error('mock createTexture failed');
781
+ const texture = texOut.value;
782
+
783
+ const out = device.createTextureView(texture, {
784
+ label: 'view-1',
785
+ format: 'rgba8unorm-srgb',
786
+ dimension: '2d',
787
+ usage: TEXTURE_BINDING,
788
+ aspect: 'all',
789
+ baseMipLevel: 0,
790
+ mipLevelCount: 1,
791
+ baseArrayLayer: 0,
792
+ arrayLayerCount: 1,
793
+ });
794
+ expect(out.ok).toBe(true);
795
+
796
+ const ev = lastOf(gpu.__captured, 'createTextureView');
797
+ expect(ev.descriptor?.label).toBe('view-1');
798
+ expect(ev.descriptor?.format).toBe('rgba8unorm-srgb');
799
+ expect(ev.descriptor?.dimension).toBe('2d');
800
+ expect(ev.descriptor?.usage).toBe(TEXTURE_BINDING);
801
+ expect(ev.descriptor?.aspect).toBe('all');
802
+ expect(ev.descriptor?.baseMipLevel).toBe(0);
803
+ expect(ev.descriptor?.mipLevelCount).toBe(1);
804
+ expect(ev.descriptor?.baseArrayLayer).toBe(0);
805
+ expect(ev.descriptor?.arrayLayerCount).toBe(1);
806
+ });
807
+ });
808
+
809
+ describe('w04 createTextureView - S-7 + hard constraint 10 - `?: T | undefined` guard', () => {
810
+ it('when label is omitted, the descriptor passed to the mock does not contain the label key', async () => {
811
+ const gpu = createMockGpu();
812
+ const r = await requestDevice({ gpu });
813
+ if (!r.ok) throw new Error('mock requestDevice failed');
814
+ const device = r.value;
815
+
816
+ const texOut = device.createTexture({
817
+ size: [16, 16],
818
+ format: 'rgba8unorm',
819
+ usage: TEXTURE_BINDING,
820
+ });
821
+ if (!texOut.ok) throw new Error('mock createTexture failed');
822
+
823
+ device.createTextureView(texOut.value, { format: 'rgba8unorm', dimension: '2d' });
824
+ const ev = lastOf(gpu.__captured, 'createTextureView');
825
+ expect('label' in (ev.descriptor ?? {})).toBe(false);
826
+ });
827
+
828
+ it('when label: undefined is explicit, the descriptor passed to the mock contains the label key with value undefined', async () => {
829
+ const gpu = createMockGpu();
830
+ const r = await requestDevice({ gpu });
831
+ if (!r.ok) throw new Error('mock requestDevice failed');
832
+ const device = r.value;
833
+
834
+ const texOut = device.createTexture({
835
+ size: [16, 16],
836
+ format: 'rgba8unorm',
837
+ usage: TEXTURE_BINDING,
838
+ });
839
+ if (!texOut.ok) throw new Error('mock createTexture failed');
840
+
841
+ device.createTextureView(texOut.value, {
842
+ label: undefined,
843
+ format: 'rgba8unorm',
844
+ dimension: '2d',
845
+ });
846
+ const ev = lastOf(gpu.__captured, 'createTextureView');
847
+ expect('label' in (ev.descriptor ?? {})).toBe(true);
848
+ expect(ev.descriptor?.label).toBeUndefined();
849
+ });
850
+ });
851
+
852
+ describe('w04 createTextureView - cross-resource validation maps to webgpu-runtime-error (research §1.1)', () => {
853
+ it("format not in source.format ∪ source.viewFormats returns 'webgpu-runtime-error'", async () => {
854
+ const gpu = createMockGpu();
855
+ const r = await requestDevice({ gpu });
856
+ if (!r.ok) throw new Error('mock requestDevice failed');
857
+ const device = r.value;
858
+
859
+ const texOut = device.createTexture({
860
+ size: [16, 16],
861
+ format: 'rgba8unorm',
862
+ usage: TEXTURE_BINDING,
863
+ viewFormats: ['rgba8unorm-srgb'],
864
+ });
865
+ if (!texOut.ok) throw new Error('mock createTexture failed');
866
+
867
+ const out = device.createTextureView(texOut.value, {
868
+ // bgra8unorm is neither the source format nor in viewFormats.
869
+ format: 'bgra8unorm',
870
+ dimension: '2d',
871
+ });
872
+ expect(out.ok).toBe(false);
873
+ if (!out.ok) {
874
+ expect(out.error.code).toBe('webgpu-runtime-error');
875
+ expect(out.error.expected.length).toBeGreaterThan(0);
876
+ expect(out.error.hint.length).toBeGreaterThan(0);
877
+ }
878
+ });
879
+
880
+ it("usage not a subset of source.usage returns 'webgpu-runtime-error'", async () => {
881
+ const gpu = createMockGpu();
882
+ const r = await requestDevice({ gpu });
883
+ if (!r.ok) throw new Error('mock requestDevice failed');
884
+ const device = r.value;
885
+
886
+ const texOut = device.createTexture({
887
+ size: [16, 16],
888
+ format: 'rgba8unorm',
889
+ usage: TEXTURE_BINDING,
890
+ });
891
+ if (!texOut.ok) throw new Error('mock createTexture failed');
892
+
893
+ const out = device.createTextureView(texOut.value, {
894
+ format: 'rgba8unorm',
895
+ dimension: '2d',
896
+ // STORAGE_BINDING was not in source usage; the subset check must fail.
897
+ usage: STORAGE_BINDING,
898
+ });
899
+ expect(out.ok).toBe(false);
900
+ if (!out.ok) {
901
+ expect(out.error.code).toBe('webgpu-runtime-error');
902
+ }
903
+ });
904
+ });
905
+
906
+ // w08 — createComputePipeline vitest unit mock red phase. TDD red asserts:
907
+ // (a) caps.compute === false -> 'feature-not-enabled' + structured templates.
908
+ // Constructed via direct RhiError contract assertion (mirror of
909
+ // createBindGroup runtime tests). The MVP shim hardcodes
910
+ // caps.compute=true (research §1.2 NOTE: WebGPU mandates compute);
911
+ // the gate exists for potential future backends that lack compute.
912
+ // (b) layout: 'auto' field passes through verbatim.
913
+ // (c) layout: PipelineLayout brand passes through verbatim.
914
+ // (d) entryPoint + constants are forwarded through the compute nested
915
+ // dictionary verbatim.
916
+ // Anchors: requirements §IN-1 / §AC-01 / §AC-07 / boundary case row 1;
917
+ // research §1.2 device timeline + §9; plan-strategy §4.3 + K-10.
918
+ describe('w08 createComputePipeline - feature-not-enabled gate template (boundary case row 1)', () => {
919
+ it("'feature-not-enabled' err carries the contracted .expected / .hint templates", () => {
920
+ const e = new RhiError({
921
+ code: 'feature-not-enabled',
922
+ expected: 'caps.compute === true',
923
+ hint: 'check device.caps.compute before calling createComputePipeline',
924
+ });
925
+ expect(e.code).toBe('feature-not-enabled');
926
+ expect(e.expected).toBe('caps.compute === true');
927
+ expect(e.hint).toBe('check device.caps.compute before calling createComputePipeline');
928
+ });
929
+ });
930
+
931
+ describe('w08 createComputePipeline - layout / compute pass-through', () => {
932
+ it("layout: 'auto' is forwarded verbatim to the underlying device", async () => {
933
+ const gpu = createMockGpu();
934
+ const r = await requestDevice({ gpu });
935
+ if (!r.ok) throw new Error('mock requestDevice failed');
936
+ const device = r.value;
937
+
938
+ const shader = await createShaderModule(device, {
939
+ code: '@compute @workgroup_size(1) fn cs() {}',
940
+ });
941
+ if (!shader.ok) throw new Error('mock shader creation failed');
942
+
943
+ const out = device.createComputePipeline({
944
+ label: 'cs-auto',
945
+ layout: 'auto',
946
+ compute: { module: shader.value, entryPoint: 'cs' },
947
+ });
948
+ expect(out.ok).toBe(true);
949
+
950
+ const ev = lastOf(gpu.__captured, 'createComputePipeline');
951
+ expect(ev.descriptor.label).toBe('cs-auto');
952
+ expect(ev.descriptor.layout).toBe('auto');
953
+ expect(ev.descriptor.compute.entryPoint).toBe('cs');
954
+ });
955
+
956
+ it('layout: PipelineLayout brand is forwarded verbatim', async () => {
957
+ const gpu = createMockGpu();
958
+ const r = await requestDevice({ gpu });
959
+ if (!r.ok) throw new Error('mock requestDevice failed');
960
+ const device = r.value;
961
+
962
+ const shader = await createShaderModule(device, {
963
+ code: '@compute @workgroup_size(1) fn cs() {}',
964
+ });
965
+ if (!shader.ok) throw new Error('mock shader creation failed');
966
+
967
+ const plOut = device.createPipelineLayout({ label: 'pl', bindGroupLayouts: [] });
968
+ expect(plOut.ok).toBe(true);
969
+ if (!plOut.ok) return;
970
+
971
+ const out = device.createComputePipeline({
972
+ label: 'cs-explicit',
973
+ layout: plOut.value,
974
+ compute: { module: shader.value, entryPoint: 'cs' },
975
+ });
976
+ expect(out.ok).toBe(true);
977
+
978
+ const ev = lastOf(gpu.__captured, 'createComputePipeline');
979
+ expect(ev.descriptor.layout).toBe(plOut.value as never);
980
+ });
981
+
982
+ it('compute.constants is forwarded verbatim', async () => {
983
+ const gpu = createMockGpu();
984
+ const r = await requestDevice({ gpu });
985
+ if (!r.ok) throw new Error('mock requestDevice failed');
986
+ const device = r.value;
987
+
988
+ const shader = await createShaderModule(device, {
989
+ code: '@compute @workgroup_size(1) fn cs() {}',
990
+ });
991
+ if (!shader.ok) throw new Error('mock shader creation failed');
992
+
993
+ const out = device.createComputePipeline({
994
+ layout: 'auto',
995
+ compute: { module: shader.value, entryPoint: 'cs', constants: { foo: 1, bar: 2 } },
996
+ });
997
+ expect(out.ok).toBe(true);
998
+
999
+ const ev = lastOf(gpu.__captured, 'createComputePipeline');
1000
+ expect(ev.descriptor.compute.constants).toEqual({ foo: 1, bar: 2 });
1001
+ });
1002
+ });
1003
+
1004
+ // w11 — createQuerySet vitest unit mock red phase + count boundary checks.
1005
+ // TDD red asserts:
1006
+ // (a) count = 0 is legal (research §1.3 lower bound; dawn end2end fixture).
1007
+ // (b) count = 4096 is legal (upper bound).
1008
+ // (c) count = 4097 returns 'limit-exceeded' with .expected =
1009
+ // 'count <= 4096 (spec normative)' + .hint =
1010
+ // 'create multiple QuerySet instances if more than 4096 queries needed'.
1011
+ // (d) type:'timestamp' + caps.timestampQuery === false returns
1012
+ // 'feature-not-enabled' (mock device defaults: timestamp-query feature
1013
+ // not in features set; shim must check caps before forwarding).
1014
+ // Anchors: requirements §IN-1 / §AC-01 / boundary case row 2; research §1.3 +
1015
+ // §7.2 Pattern D + §9; plan-strategy §3 R-1 mitigation + §4.3 +
1016
+ // K-10.
1017
+ describe('w11 createQuerySet - count boundary 0 / 4096 / 4097', () => {
1018
+ it('count = 0 is legal (research §1.3 lower bound)', async () => {
1019
+ const gpu = createMockGpu();
1020
+ const r = await requestDevice({ gpu });
1021
+ if (!r.ok) throw new Error('mock requestDevice failed');
1022
+ const device = r.value;
1023
+
1024
+ const out = device.createQuerySet({ label: 'qs-zero', type: 'occlusion', count: 0 });
1025
+ expect(out.ok).toBe(true);
1026
+
1027
+ const ev = lastOf(gpu.__captured, 'createQuerySet');
1028
+ expect(ev.descriptor.count).toBe(0);
1029
+ expect(ev.descriptor.type).toBe('occlusion');
1030
+ });
1031
+
1032
+ it('count = 4096 is legal (research §1.3 upper bound)', async () => {
1033
+ const gpu = createMockGpu();
1034
+ const r = await requestDevice({ gpu });
1035
+ if (!r.ok) throw new Error('mock requestDevice failed');
1036
+ const device = r.value;
1037
+
1038
+ const out = device.createQuerySet({ type: 'occlusion', count: 4096 });
1039
+ expect(out.ok).toBe(true);
1040
+
1041
+ const ev = lastOf(gpu.__captured, 'createQuerySet');
1042
+ expect(ev.descriptor.count).toBe(4096);
1043
+ });
1044
+
1045
+ it("count = 4097 returns 'limit-exceeded' with the contracted templates", async () => {
1046
+ const gpu = createMockGpu();
1047
+ const r = await requestDevice({ gpu });
1048
+ if (!r.ok) throw new Error('mock requestDevice failed');
1049
+ const device = r.value;
1050
+
1051
+ const out = device.createQuerySet({ type: 'occlusion', count: 4097 });
1052
+ expect(out.ok).toBe(false);
1053
+ if (!out.ok) {
1054
+ expect(out.error.code).toBe('limit-exceeded');
1055
+ expect(out.error.expected).toBe('count <= 4096 (spec normative)');
1056
+ expect(out.error.hint).toBe(
1057
+ 'create multiple QuerySet instances if more than 4096 queries needed',
1058
+ );
1059
+ }
1060
+ });
1061
+ });
1062
+
1063
+ describe('w11 createQuerySet - timestamp feature gate (boundary case row 2)', () => {
1064
+ it("type: 'timestamp' + caps.timestampQuery === false returns 'feature-not-enabled'", async () => {
1065
+ const gpu = createMockGpu();
1066
+ const r = await requestDevice({ gpu });
1067
+ if (!r.ok) throw new Error('mock requestDevice failed');
1068
+ const device = r.value;
1069
+ // The default mock features set is empty -> deriveCaps yields
1070
+ // timestampQuery=false, so the shim's gate must trip.
1071
+ expect(device.caps.timestampQuery).toBe(false);
1072
+
1073
+ const out = device.createQuerySet({ type: 'timestamp', count: 4 });
1074
+ expect(out.ok).toBe(false);
1075
+ if (!out.ok) {
1076
+ expect(out.error.code).toBe('feature-not-enabled');
1077
+ expect(out.error.expected.length).toBeGreaterThan(0);
1078
+ expect(out.error.hint.length).toBeGreaterThan(0);
1079
+ }
1080
+ });
1081
+ });
1082
+
1083
+ // w20 - RhiCanvasContext vitest unit (mock) red phase. TDD red asserts:
1084
+ // (a) format not in {'bgra8unorm','rgba8unorm','rgba16float'} -> error
1085
+ // 'webgpu-runtime-error' + .expected = 'one of bgra8unorm/rgba8unorm/rgba16float'.
1086
+ // (b) device invalid | lost -> error 'rhi-not-available'.
1087
+ // (c) getCurrentTexture() while unconfigured -> 'webgpu-runtime-error'
1088
+ // (spec InvalidStateError mapping).
1089
+ // (d) currentTexture is not cached across frames (each call returns a fresh
1090
+ // texture handle; mock context emits a new sentinel per call).
1091
+ //
1092
+ // Anchors: requirements §IN-4 / §AC-04 / §AC-07 / boundary case row 7;
1093
+ // research §3.3 4 methods + §9 error code mapping; plan-strategy
1094
+ // §4.2 + §4.3 RhiCanvasContext + K-10.
1095
+
1096
+ interface MockGpuTexture {
1097
+ readonly __brand: 'mock-gpu-texture';
1098
+ readonly id: number;
1099
+ }
1100
+
1101
+ interface MockGpuCanvasContext {
1102
+ __configured: boolean;
1103
+ __currentTextureCalls: number;
1104
+ __invalidStateOnGetTexture: boolean;
1105
+ configure(desc: GPUCanvasConfiguration): void;
1106
+ unconfigure(): void;
1107
+ getConfiguration(): GPUCanvasConfiguration | null;
1108
+ getCurrentTexture(): MockGpuTexture;
1109
+ }
1110
+
1111
+ function makeMockCanvasContext(): MockGpuCanvasContext {
1112
+ let storedConfig: GPUCanvasConfiguration | null = null;
1113
+ let counter = 0;
1114
+ const ctx: MockGpuCanvasContext = {
1115
+ __configured: false,
1116
+ __currentTextureCalls: 0,
1117
+ __invalidStateOnGetTexture: false,
1118
+ configure(desc) {
1119
+ storedConfig = desc;
1120
+ ctx.__configured = true;
1121
+ },
1122
+ unconfigure() {
1123
+ storedConfig = null;
1124
+ ctx.__configured = false;
1125
+ },
1126
+ getConfiguration() {
1127
+ return storedConfig;
1128
+ },
1129
+ getCurrentTexture() {
1130
+ ctx.__currentTextureCalls += 1;
1131
+ if (ctx.__invalidStateOnGetTexture) {
1132
+ const e = new Error('mock: InvalidStateError - context unconfigured');
1133
+ e.name = 'InvalidStateError';
1134
+ throw e;
1135
+ }
1136
+ counter += 1;
1137
+ return { __brand: 'mock-gpu-texture' as const, id: counter };
1138
+ },
1139
+ };
1140
+ return ctx;
1141
+ }
1142
+
1143
+ async function freshDeviceAndContext(): Promise<{
1144
+ device: import('@forgeax/engine-rhi').RhiDevice;
1145
+ rhiContext: RhiCanvasContext;
1146
+ rawContext: MockGpuCanvasContext;
1147
+ }> {
1148
+ const gpu = createMockGpu();
1149
+ const r = await requestDevice({ gpu });
1150
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1151
+ const device = r.value;
1152
+ const rawContext = makeMockCanvasContext();
1153
+ const mockCanvas = { getContext: () => rawContext };
1154
+ const ctxResult = acquireCanvasContext(mockCanvas as unknown as HTMLCanvasElement);
1155
+ if (!ctxResult.ok) throw new Error('acquireCanvasContext should not fail in unit fixture');
1156
+ return { device, rhiContext: ctxResult.value, rawContext };
1157
+ }
1158
+
1159
+ describe('w20 RhiCanvasContext - format gate maps to webgpu-runtime-error (research §3.2 supported context formats)', () => {
1160
+ it("format = 'rgba8unorm-srgb' (NOT in {'bgra8unorm','rgba8unorm','rgba16float'}) returns webgpu-runtime-error with the spec-aligned expected template", async () => {
1161
+ const { device, rhiContext } = await freshDeviceAndContext();
1162
+ const cfg: CanvasConfiguration = {
1163
+ device,
1164
+ format: 'rgba8unorm-srgb',
1165
+ usage: 0x10,
1166
+ };
1167
+ const out = rhiContext.configure(cfg);
1168
+ expect(out.ok).toBe(false);
1169
+ if (!out.ok) {
1170
+ expect(out.error.code).toBe('webgpu-runtime-error');
1171
+ expect(out.error.expected).toBe('one of bgra8unorm/rgba8unorm/rgba16float');
1172
+ expect(out.error.hint.length).toBeGreaterThan(0);
1173
+ }
1174
+ });
1175
+
1176
+ it("format = 'bgra8unorm' is legal (one of the 3 supported context formats)", async () => {
1177
+ const { device, rhiContext, rawContext } = await freshDeviceAndContext();
1178
+ const out = rhiContext.configure({
1179
+ device,
1180
+ format: 'bgra8unorm',
1181
+ usage: 0x10,
1182
+ });
1183
+ expect(out.ok).toBe(true);
1184
+ expect(rawContext.__configured).toBe(true);
1185
+ });
1186
+ });
1187
+
1188
+ describe('w20 RhiCanvasContext - getCurrentTexture in unconfigured state maps to webgpu-runtime-error (spec InvalidStateError)', () => {
1189
+ it('calling getCurrentTexture() before configure() returns webgpu-runtime-error', async () => {
1190
+ const { rhiContext, rawContext } = await freshDeviceAndContext();
1191
+ rawContext.__invalidStateOnGetTexture = true;
1192
+ const out = rhiContext.getCurrentTexture();
1193
+ expect(out.ok).toBe(false);
1194
+ if (!out.ok) {
1195
+ expect(out.error.code).toBe('webgpu-runtime-error');
1196
+ expect(out.error.expected.length).toBeGreaterThan(0);
1197
+ expect(out.error.hint.length).toBeGreaterThan(0);
1198
+ }
1199
+ });
1200
+ });
1201
+
1202
+ describe('w20 RhiCanvasContext - currentTexture is NOT cached across calls (research §3.1 [[Expire the current texture]])', () => {
1203
+ it('two consecutive getCurrentTexture() calls each go to the underlying context (per-frame fetch)', async () => {
1204
+ const { device, rhiContext, rawContext } = await freshDeviceAndContext();
1205
+ const cfg: CanvasConfiguration = {
1206
+ device,
1207
+ format: 'bgra8unorm',
1208
+ usage: 0x10,
1209
+ };
1210
+ expect(rhiContext.configure(cfg).ok).toBe(true);
1211
+ const a = rhiContext.getCurrentTexture();
1212
+ const b = rhiContext.getCurrentTexture();
1213
+ expect(a.ok).toBe(true);
1214
+ expect(b.ok).toBe(true);
1215
+ // The mock counter advances on every call -> the shim does NOT short-circuit.
1216
+ expect(rawContext.__currentTextureCalls).toBe(2);
1217
+ });
1218
+ });
1219
+
1220
+ describe('w20 RhiCanvasContext - getConfiguration returns spec projection (feature-detection entry, research §3.2 toneMapping NOTE)', () => {
1221
+ it('after configure() the configuration is observable; after unconfigure() it returns undefined', async () => {
1222
+ const { device, rhiContext } = await freshDeviceAndContext();
1223
+ expect(rhiContext.getConfiguration()).toBeUndefined();
1224
+ expect(
1225
+ rhiContext.configure({
1226
+ device,
1227
+ format: 'bgra8unorm',
1228
+ usage: 0x10,
1229
+ }).ok,
1230
+ ).toBe(true);
1231
+ const conf = rhiContext.getConfiguration();
1232
+ expect(conf).toBeDefined();
1233
+ if (conf) {
1234
+ expect(conf.format).toBe('bgra8unorm');
1235
+ }
1236
+ rhiContext.unconfigure();
1237
+ expect(rhiContext.getConfiguration()).toBeUndefined();
1238
+ });
1239
+ });
1240
+ }
1241
+
1242
+ {
1243
+ // --- from error-scope-removed.test.ts ---
1244
+ // AC-01 + AC-04 + D-I2 — error scope removed test.
1245
+ //
1246
+ // This test ratifies the post-realign dispatch model: createX entries return
1247
+ // synchronously (Result<T, RhiError>), NOT Promise<Result>; the device.lost +
1248
+ // onuncapturederror channels carry async error fan-out (separately, via the
1249
+ // @forgeax/engine-runtime RhiErrorListenerRegistry at the engine layer).
1250
+ //
1251
+ // Per implement-decisions.md D-I2: the "delete push/pop+await + register
1252
+ // onuncapturederror listener" half of plan-strategy §S-2 is **vacuously
1253
+ // satisfied** — packages/rhi-webgpu/src/ never contained pushErrorScope /
1254
+ // popErrorScope wrappers; the existing direct-dispatch path already aligns
1255
+ // with the new model.
1256
+ //
1257
+ // Three-part assertion structure:
1258
+ // (a) src code has 0 pushErrorScope / popErrorScope hits (D-I2 vacuous
1259
+ // satisfaction grep gate);
1260
+ // (b) createBuffer with valid descriptor returns Result.ok synchronously
1261
+ // (the returned value is a Buffer wrapper, not a thenable);
1262
+ // (c) createBindGroupLayout / createPipelineLayout / createRenderPipeline
1263
+ // all return synchronously (Result.ok | Result.err with .ok
1264
+ // discriminator, never Promise).
1265
+ //
1266
+ // Rationale: requirements AC-01 (delete push/pop+await + spec async dispatch
1267
+ // passthrough); research R-01 §1 chromium DispatchEvent same-tick fact;
1268
+ // plan-strategy D-P6 dual channel boundary; charter proposition 4 + 5.
1269
+
1270
+ // Lazy-load node:fs / node:path through dynamic import so the test file
1271
+ // itself does not require @types/node at the package boundary (the shim
1272
+ // package is intentionally browser-leaning per AGENTS.md "## Packages"
1273
+ // table). The @ts-expect-error escape lets tsc skip the node: lookups
1274
+ // without polluting the package's compilerOptions.types with "node".
1275
+ async function loadShimSrcFiles(): Promise<readonly string[]> {
1276
+ // @ts-expect-error node: specifier not in tsconfig types (vitest runtime only)
1277
+ const nodeFs = await import('node:fs');
1278
+ // @ts-expect-error node: specifier not in tsconfig types
1279
+ const nodePath = await import('node:path');
1280
+ // @ts-expect-error node: specifier not in tsconfig types
1281
+ const nodeUrl = await import('node:url');
1282
+ const here = nodePath.dirname(nodeUrl.fileURLToPath(import.meta.url));
1283
+ const srcDir = nodePath.resolve(here, '..');
1284
+ return (['index.ts', 'device.ts', 'errors.ts'] as const).map((name): string =>
1285
+ nodeFs.readFileSync(nodePath.join(srcDir, name), 'utf8'),
1286
+ );
1287
+ }
1288
+
1289
+ describe('AC-01 — D-I2 vacuous satisfaction: no pushErrorScope / popErrorScope in shim src', () => {
1290
+ it('packages/rhi-webgpu/src/*.ts has 0 pushErrorScope hits (grep gate)', async () => {
1291
+ const bodies = await loadShimSrcFiles();
1292
+ for (const body of bodies) {
1293
+ // Strip comment lines so a documentary reference doesn't trip the gate.
1294
+ const code = body
1295
+ .split('\n')
1296
+ .filter((line: string) => !/^\s*(\*|\/\/)/.test(line))
1297
+ .join('\n');
1298
+ expect(code).not.toMatch(/\bpushErrorScope\b/);
1299
+ expect(code).not.toMatch(/\bpopErrorScope\b/);
1300
+ }
1301
+ });
1302
+ });
1303
+
1304
+ describe('AC-04 — createX returns Result synchronously (Result.ok | Result.err)', () => {
1305
+ it('createBuffer returns Result<Buffer, RhiError> synchronously (not Promise)', async () => {
1306
+ const gpu = createMockGpu({});
1307
+ const r = await requestDevice({ gpu });
1308
+ expect(r.ok).toBe(true);
1309
+ if (!r.ok) return;
1310
+ const device = r.value;
1311
+ const out = device.createBuffer({ size: 64, usage: 0x8 /* COPY_DST */ });
1312
+ // Result is a synchronous value with `.ok` discriminator; the Promise
1313
+ // form (Promise<Result>) is reserved for entries that need to await spec
1314
+ // async dispatch (e.g. requestAdapter / requestDevice / mapAsync) per
1315
+ // plan-strategy §7.1 sync / async axis.
1316
+ expect(typeof (out as unknown as { then?: unknown }).then).toBe('undefined');
1317
+ expect(out.ok).toBe(true);
1318
+ });
1319
+
1320
+ it('createBindGroupLayout returns Result synchronously', async () => {
1321
+ const gpu = createMockGpu({});
1322
+ const r = await requestDevice({ gpu });
1323
+ expect(r.ok).toBe(true);
1324
+ if (!r.ok) return;
1325
+ const device = r.value;
1326
+ const out = device.createBindGroupLayout({ entries: [] });
1327
+ expect(typeof (out as unknown as { then?: unknown }).then).toBe('undefined');
1328
+ expect(out.ok).toBe(true);
1329
+ });
1330
+ });
1331
+ }
1332
+
1333
+ {
1334
+ // --- from errors.test.ts ---
1335
+ // AC-10 + MVP-1.7 runtime — 4 error-path Result.err three-field assertions +
1336
+ // closed-union completeness. After feat-20260508-rhi-surface-completion w7
1337
+ // (D-S3) the union has 17 members; this test still drives the 4 error paths
1338
+ // reachable via mock GPU + asserts triggered codes are a subset of the 10
1339
+ // closed-union members.
1340
+ //
1341
+ // 4 error paths (boundary cases / plan-strategy 7.3 error-info table / research F-5):
1342
+ // 1) adapter null -> code 'adapter-unavailable'
1343
+ // 2) feature not enabled -> code 'feature-not-enabled'
1344
+ // 3) limit exceeded -> code 'limit-exceeded'
1345
+ // 4) shader compile failed -> code 'shader-compile-failed' + detail.compilerMessages[]
1346
+ //
1347
+ // MVP-1.7 runtime gate: aggregate assertion that triggered codes belong to
1348
+ // RhiErrorCode union 17 members (extended in w7 from 6).
1349
+ // plan-decisions OQ-P2: detail.compilerMessages forwards the full 6-field
1350
+ // GPUCompilationMessage shape.
1351
+ //
1352
+ // Related: requirements AC-10 + MVP-1.7 + boundary cases + AI User Affordances;
1353
+ // plan-strategy R1 / R10 + 4.3 key test points #4 / #5 + 7.3 error-info table;
1354
+ // research F-3 (full GPUCompilationMessage fields) + F-5 (single null channel).
1355
+
1356
+ /** 17-member closed-union runtime enumeration; used by the MVP-1.7 aggregate gate.
1357
+ * Extended in feat-20260511-rhi-spec-realign-aggressive w6 (+ 'device-lost' /
1358
+ * 'oom' / 'internal-error' for spec §22.2 three-subtype dispatch) +
1359
+ * feat-20260509-ecs-render-bridge-mvp w6 (+ render-system 4 codes).
1360
+ */
1361
+ const RHI_ERROR_CODES: ReadonlySet<RhiErrorCode> = new Set([
1362
+ 'adapter-unavailable',
1363
+ 'feature-not-enabled',
1364
+ 'limit-exceeded',
1365
+ 'shader-compile-failed',
1366
+ 'rhi-not-available',
1367
+ 'webgpu-runtime-error',
1368
+ 'command-encoder-finished',
1369
+ 'render-pass-not-ended',
1370
+ 'queue-submit-failed',
1371
+ 'queue-write-buffer-out-of-bounds',
1372
+ 'render-system-no-camera',
1373
+ 'render-system-multi-camera',
1374
+ 'render-system-multi-light',
1375
+ 'asset-not-registered',
1376
+ 'device-lost',
1377
+ 'oom',
1378
+ 'internal-error',
1379
+ ]);
1380
+
1381
+ function unwrapErr<T>(r: Result<T, RhiError>): RhiError {
1382
+ if (r.ok) throw new Error('expected Result.err but got Result.ok');
1383
+ return r.error;
1384
+ }
1385
+
1386
+ describe('AC-10 — 4 error paths .code / .expected / .hint three-field assertions', () => {
1387
+ afterEach(() => vi.unstubAllGlobals());
1388
+
1389
+ it('adapter null -> code=adapter-unavailable + three non-empty string fields', async () => {
1390
+ const gpu = createMockGpu({ adapterNull: true });
1391
+ const e = unwrapErr(await requestDevice({ gpu }));
1392
+ expect(e).toBeInstanceOf(RhiError);
1393
+ expect(e.code).toBe('adapter-unavailable');
1394
+ expect(typeof e.expected).toBe('string');
1395
+ expect(e.expected.length).toBeGreaterThan(0);
1396
+ expect(typeof e.hint).toBe('string');
1397
+ expect(e.hint.length).toBeGreaterThan(0);
1398
+ });
1399
+
1400
+ it('requestAdapter throw keeps the original failure instead of claiming adapter-unavailable', async () => {
1401
+ vi.stubGlobal('navigator', {
1402
+ gpu: {
1403
+ requestAdapter: async () => {
1404
+ throw new DOMException('WebGPU permission policy denied access', 'SecurityError');
1405
+ },
1406
+ },
1407
+ });
1408
+
1409
+ const e = unwrapErr(await requestAdapter());
1410
+ expect(e.code).toBe('webgpu-runtime-error');
1411
+ expect(e.hint).toContain('wgpu/WebGL2 fallback');
1412
+ expect(e.detail).toEqual({
1413
+ error: {
1414
+ code: 'request-adapter-threw',
1415
+ name: 'SecurityError',
1416
+ message: 'WebGPU permission policy denied access',
1417
+ },
1418
+ });
1419
+ });
1420
+
1421
+ it('feature not enabled -> code=feature-not-enabled + three non-empty string fields', async () => {
1422
+ const gpu = createMockGpu({ requestDeviceFeatureNotEnabled: true });
1423
+ const e = unwrapErr(await requestDevice({ gpu }));
1424
+ expect(e.code).toBe('feature-not-enabled');
1425
+ expect(e.expected.length).toBeGreaterThan(0);
1426
+ expect(e.hint.length).toBeGreaterThan(0);
1427
+ });
1428
+
1429
+ it('limit exceeded -> code=limit-exceeded + three non-empty string fields', async () => {
1430
+ const gpu = createMockGpu({ requestDeviceLimitExceeded: true });
1431
+ const e = unwrapErr(await requestDevice({ gpu }));
1432
+ expect(e.code).toBe('limit-exceeded');
1433
+ expect(e.expected.length).toBeGreaterThan(0);
1434
+ expect(e.hint.length).toBeGreaterThan(0);
1435
+ });
1436
+
1437
+ it('shader compile failed -> code=shader-compile-failed + 3 fields + detail.compilerMessages 6-field passthrough', async () => {
1438
+ const compileMsg = makeShaderError({
1439
+ message: 'expected `;`',
1440
+ type: 'error',
1441
+ lineNum: 3,
1442
+ linePos: 12,
1443
+ offset: 42,
1444
+ length: 5,
1445
+ });
1446
+ const gpu = createMockGpu({ shaderCompileMessages: [compileMsg] });
1447
+ const r = await requestDevice({ gpu });
1448
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1449
+ const device = r.value;
1450
+
1451
+ const shaderResult = await createShaderModule(device, { code: 'fn bad() { ; }' });
1452
+ const e = unwrapErr(shaderResult);
1453
+ expect(e.code).toBe('shader-compile-failed');
1454
+ expect(e.expected.length).toBeGreaterThan(0);
1455
+ expect(e.hint.length).toBeGreaterThan(0);
1456
+
1457
+ // OQ-P2: detail.compilerMessages forwards the 6-field GPUCompilationMessage shape (F-3 finding).
1458
+ // After D-S7 the .detail field is a union (RhiShaderCompileDetail |
1459
+ // RhiAssetNotRegisteredDetail | RhiWebgpuRuntimeDetail | undefined); on
1460
+ // the 'shader-compile-failed' path the 'compilerMessages' branch is the
1461
+ // narrowed shape.
1462
+ expect(e.detail).toBeDefined();
1463
+ expect(e.detail !== undefined && 'compilerMessages' in e.detail).toBe(true);
1464
+ if (e.detail === undefined || !('compilerMessages' in e.detail)) {
1465
+ throw new Error('expected RhiShaderCompileDetail');
1466
+ }
1467
+ expect(e.detail.compilerMessages.length).toBe(1);
1468
+ const msg = e.detail.compilerMessages[0];
1469
+ expect(msg?.message).toBe('expected `;`');
1470
+ expect(msg?.type).toBe('error');
1471
+ expect(msg?.lineNum).toBe(3);
1472
+ expect(msg?.linePos).toBe(12);
1473
+ expect(msg?.offset).toBe(42);
1474
+ expect(msg?.length).toBe(5);
1475
+ });
1476
+
1477
+ it('other 4 paths have detail === undefined (charter proposition 4 baseline / shader-compile-failed exclusive)', async () => {
1478
+ const gpu = createMockGpu({ adapterNull: true });
1479
+ const e = unwrapErr(await requestDevice({ gpu }));
1480
+ expect(e.detail).toBeUndefined();
1481
+ });
1482
+ });
1483
+
1484
+ describe('MVP-1.7 runtime — closed RhiErrorCode union 17 members aggregate gate', () => {
1485
+ it('4 error paths triggered codes are strict subset of RhiErrorCode union 17 members', async () => {
1486
+ const triggered = new Set<RhiErrorCode>();
1487
+
1488
+ {
1489
+ const gpu = createMockGpu({ adapterNull: true });
1490
+ triggered.add(unwrapErr(await requestDevice({ gpu })).code);
1491
+ }
1492
+ {
1493
+ const gpu = createMockGpu({ requestDeviceFeatureNotEnabled: true });
1494
+ triggered.add(unwrapErr(await requestDevice({ gpu })).code);
1495
+ }
1496
+ {
1497
+ const gpu = createMockGpu({ requestDeviceLimitExceeded: true });
1498
+ triggered.add(unwrapErr(await requestDevice({ gpu })).code);
1499
+ }
1500
+ {
1501
+ const gpu = createMockGpu({ shaderCompileMessages: [makeShaderError()] });
1502
+ const r = await requestDevice({ gpu });
1503
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1504
+ const sr = await createShaderModule(r.value, { code: 'bad' });
1505
+ triggered.add(unwrapErr(sr).code);
1506
+ }
1507
+
1508
+ // 4 error paths trigger 4 of the 17 union members (rest are validation/runtime
1509
+ // paths covered by other tests: w6 queue, w3/w5 encoder/pass, K-9 silent-skip).
1510
+ expect(triggered.size).toBe(4);
1511
+ for (const c of triggered) {
1512
+ expect(RHI_ERROR_CODES.has(c)).toBe(true);
1513
+ }
1514
+ expect(triggered.has('adapter-unavailable')).toBe(true);
1515
+ expect(triggered.has('feature-not-enabled')).toBe(true);
1516
+ expect(triggered.has('limit-exceeded')).toBe(true);
1517
+ expect(triggered.has('shader-compile-failed')).toBe(true);
1518
+ });
1519
+ });
1520
+
1521
+ describe('createShaderModule — getCompilationInfo rejection on dropped instance', () => {
1522
+ // Regression: when the underlying GPU instance is dropped mid-await (device
1523
+ // destroyed / page teardown while getCompilationInfo() is in flight), the
1524
+ // promise rejects with OperationError 'Instance dropped'. The handle was
1525
+ // already created synchronously, so createShaderModule must swallow the
1526
+ // teardown rejection and return ok rather than let it escape as an unhandled
1527
+ // rejection (observed as 5 CI unhandled rejections from the learn-render
1528
+ // 2.lighting browser tests; charter proposition 9 graceful degradation).
1529
+ it('returns ok instead of rejecting when getCompilationInfo() rejects', async () => {
1530
+ const gpu = createMockGpu({ getCompilationInfoRejects: true });
1531
+ const r = await requestDevice({ gpu });
1532
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1533
+
1534
+ // Must resolve (not reject) and yield Result.ok.
1535
+ const sr = await createShaderModule(r.value, { code: 'fn main() {}' });
1536
+ expect(sr.ok).toBe(true);
1537
+ });
1538
+ });
1539
+ }
1540
+
1541
+ {
1542
+ // --- from queue-real-path.test.ts ---
1543
+ // w6 - RhiQueue real-path implementation + bounds validation.
1544
+ //
1545
+ // Replaces the obsolete queue-not-available.test.ts (Round 1 placeholder
1546
+ // returned 'rhi-not-available' for both submit / writeBuffer; w6 lands the
1547
+ // real shim path forwarding to GPUQueue + bounds validation).
1548
+ //
1549
+ // Coverage:
1550
+ // - submit forwards to rawQueue.submit; default mock no-ops -> Result.ok.
1551
+ // - submit with throwing rawQueue.submit -> Result.err({ code:
1552
+ // 'queue-submit-failed' }) per D-S3 template 3.
1553
+ // - writeBuffer with valid offset/size -> Result.ok.
1554
+ // - writeBuffer with non-4-byte-aligned offset -> Result.err({ code:
1555
+ // 'queue-write-buffer-out-of-bounds' }) per D-S3 template 4.
1556
+ // - writeBuffer with offset + byteLength > buffer.size -> Result.err({
1557
+ // code: 'queue-write-buffer-out-of-bounds' }) per D-S3 template 4.
1558
+ //
1559
+ // Charter: proposition 4 (explicit failure: bounds errors carry both code
1560
+ // and concrete numeric values via .hint).
1561
+ //
1562
+ // dawn.node real-GPU coverage of these paths is in w17 (M5 integration).
1563
+
1564
+ interface OkLike<T> {
1565
+ ok: true;
1566
+ value: T;
1567
+ }
1568
+ interface ErrLike {
1569
+ ok: false;
1570
+ error: { code: string; expected: string; hint: string };
1571
+ }
1572
+ type ResultLike<T> = OkLike<T> | ErrLike;
1573
+
1574
+ interface DeviceLike {
1575
+ queue: {
1576
+ submit: (commandBuffers: readonly unknown[]) => ResultLike<void>;
1577
+ writeBuffer: (
1578
+ buffer: unknown,
1579
+ bufferOffset: number,
1580
+ data: ArrayBufferView | ArrayBuffer,
1581
+ dataOffset?: number,
1582
+ size?: number,
1583
+ ) => ResultLike<void>;
1584
+ };
1585
+ createBuffer: (desc: { size: number; usage: number }) => ResultLike<unknown>;
1586
+ createCommandEncoder: (desc?: unknown) => ResultLike<{
1587
+ finish: () => ResultLike<unknown>;
1588
+ }>;
1589
+ }
1590
+
1591
+ describe('w6 - RhiQueue.submit real-path', () => {
1592
+ it('submit() with empty list returns Result.ok(undefined)', async () => {
1593
+ const gpu = createMockGpu();
1594
+ const r = await requestDevice({ gpu });
1595
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1596
+ const device = r.value as unknown as DeviceLike;
1597
+ const out = device.queue.submit([]);
1598
+ expect(out.ok).toBe(true);
1599
+ });
1600
+
1601
+ it('submit() with command buffer from finished encoder returns Result.ok', async () => {
1602
+ const gpu = createMockGpu();
1603
+ const r = await requestDevice({ gpu });
1604
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1605
+ const device = r.value as unknown as DeviceLike;
1606
+ const encResult = device.createCommandEncoder();
1607
+ if (!encResult.ok) throw new Error('createCommandEncoder should succeed');
1608
+ const finishResult = encResult.value.finish();
1609
+ if (!finishResult.ok) throw new Error('finish should succeed');
1610
+ const out = device.queue.submit([finishResult.value]);
1611
+ expect(out.ok).toBe(true);
1612
+ });
1613
+ });
1614
+
1615
+ describe('w6 - RhiQueue.writeBuffer real-path + bounds validation', () => {
1616
+ it('writeBuffer with aligned offset + in-bounds data returns Result.ok', async () => {
1617
+ const gpu = createMockGpu();
1618
+ const r = await requestDevice({ gpu });
1619
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1620
+ const device = r.value as unknown as DeviceLike;
1621
+ const bufResult = device.createBuffer({ size: 256, usage: 0 });
1622
+ if (!bufResult.ok) throw new Error('createBuffer should succeed');
1623
+ const data = new Uint8Array(64);
1624
+ const out = device.queue.writeBuffer(bufResult.value, 0, data);
1625
+ expect(out.ok).toBe(true);
1626
+ });
1627
+
1628
+ it('writeBuffer with non-4-byte-aligned offset returns queue-write-buffer-out-of-bounds', async () => {
1629
+ const gpu = createMockGpu();
1630
+ const r = await requestDevice({ gpu });
1631
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1632
+ const device = r.value as unknown as DeviceLike;
1633
+ const bufResult = device.createBuffer({ size: 256, usage: 0 });
1634
+ if (!bufResult.ok) throw new Error('createBuffer should succeed');
1635
+ const data = new Uint8Array(64);
1636
+ // Offset 3 is NOT 4-byte aligned -> structured error per D-S3 template 4.
1637
+ const out = device.queue.writeBuffer(bufResult.value, 3, data);
1638
+ expect(out.ok).toBe(false);
1639
+ if (!out.ok) {
1640
+ expect(out.error.code).toBe('queue-write-buffer-out-of-bounds');
1641
+ // Hint must carry concrete numeric values for AI-user routing.
1642
+ expect(out.error.hint).toContain('got 3');
1643
+ expect(out.error.expected).toContain('4-byte');
1644
+ }
1645
+ });
1646
+
1647
+ it('writeBuffer with offset + byteLength > buffer.size returns queue-write-buffer-out-of-bounds', async () => {
1648
+ const gpu = createMockGpu();
1649
+ const r = await requestDevice({ gpu });
1650
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1651
+ const device = r.value as unknown as DeviceLike;
1652
+ const bufResult = device.createBuffer({ size: 16, usage: 0 });
1653
+ if (!bufResult.ok) throw new Error('createBuffer should succeed');
1654
+ const data = new Uint8Array(32);
1655
+ // offset 0 + byteLength 32 > buffer.size 16 -> out-of-bounds.
1656
+ const out = device.queue.writeBuffer(bufResult.value, 0, data);
1657
+ expect(out.ok).toBe(false);
1658
+ if (!out.ok) {
1659
+ expect(out.error.code).toBe('queue-write-buffer-out-of-bounds');
1660
+ // Hint must include the buffer size and byteLength so the AI user can
1661
+ // self-recover (charter proposition 4 explicit failure with concrete
1662
+ // numeric context).
1663
+ expect(out.error.hint).toContain('got 0');
1664
+ expect(out.error.hint).toContain('got 32');
1665
+ expect(out.error.hint).toContain('got 16');
1666
+ }
1667
+ });
1668
+
1669
+ it('writeBuffer with offset + size > buffer.size (using explicit size arg) is rejected', async () => {
1670
+ const gpu = createMockGpu();
1671
+ const r = await requestDevice({ gpu });
1672
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1673
+ const device = r.value as unknown as DeviceLike;
1674
+ const bufResult = device.createBuffer({ size: 64, usage: 0 });
1675
+ if (!bufResult.ok) throw new Error('createBuffer should succeed');
1676
+ const data = new Uint8Array(128);
1677
+ // explicit size = 100, bufferOffset = 0 -> 0 + 100 > 64.
1678
+ const out = device.queue.writeBuffer(bufResult.value, 0, data, 0, 100);
1679
+ expect(out.ok).toBe(false);
1680
+ if (!out.ok) {
1681
+ expect(out.error.code).toBe('queue-write-buffer-out-of-bounds');
1682
+ }
1683
+ });
1684
+ });
1685
+
1686
+ // ---------------------------------------------------------------------------
1687
+ // w34 (M5) - mapAsync 8-item validation + F-8 row 1/2/3 unit tests.
1688
+ // ---------------------------------------------------------------------------
1689
+ //
1690
+ // research §4.2 lists 8 validation steps (4 device-timeline + 4 boundary) plus
1691
+ // the F-8 three-row contract from requirements:
1692
+ // F-8 row 1: already-mapped + mapAsync -> 'webgpu-runtime-error'
1693
+ // F-8 row 2: detached ArrayBuffer access -> 'webgpu-runtime-error'
1694
+ // F-8 row 3: mode-usage mismatch -> 'webgpu-runtime-error'
1695
+ //
1696
+ // K-2 (plan-strategy §2): all 8 validation rows + F-8 three rows ride on the
1697
+ // 'webgpu-runtime-error' code with structured `.expected` + `.hint` literals.
1698
+ // AI users route via:
1699
+ // switch (err.code) { case 'webgpu-runtime-error': ... err.expected ... }
1700
+ // (charter proposition 4 explicit failure; F-3 ai-user-review carry-over
1701
+ // requires `.code` + `.expected` + `.hint` triple grep below).
1702
+ //
1703
+ // These tests target the SHIM (packages/rhi-webgpu/src/device.ts) - the shim
1704
+ // validates BEFORE delegating to the raw GPUBuffer. The buffer interface is
1705
+ // extended in w35 with mapAsync / getMappedRange / unmap / mapState; until
1706
+ // then the test cases below are TS-red (`r.value.mapAsync` does not exist).
1707
+
1708
+ interface BufferLikeWithMap {
1709
+ mapAsync: (mode: number, offset?: number, size?: number) => Promise<ResultLike<void>>;
1710
+ getMappedRange: (offset?: number, size?: number) => ResultLike<ArrayBuffer>;
1711
+ unmap: () => void;
1712
+ readonly mapState: 'unmapped' | 'pending' | 'mapped';
1713
+ }
1714
+
1715
+ const MAP_READ = 0x1;
1716
+ const MAP_WRITE = 0x2;
1717
+ const USAGE_MAP_READ = 0x0001;
1718
+ const USAGE_MAP_WRITE = 0x0002;
1719
+ const USAGE_COPY_DST = 0x0008;
1720
+
1721
+ async function makeBuffer(desc: {
1722
+ size: number;
1723
+ usage: number;
1724
+ mappedAtCreation?: boolean;
1725
+ }): Promise<BufferLikeWithMap> {
1726
+ const gpu = createMockGpu();
1727
+ const r = await requestDevice({ gpu });
1728
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1729
+ const device = r.value as unknown as {
1730
+ createBuffer: (d: typeof desc) => ResultLike<BufferLikeWithMap>;
1731
+ };
1732
+ const buf = device.createBuffer(desc);
1733
+ if (!buf.ok) throw new Error('createBuffer should succeed');
1734
+ return buf.value;
1735
+ }
1736
+
1737
+ describe('w34 (M5) - mapAsync 8-item validation + F-8 row 1/2/3 (K-2 webgpu-runtime-error)', () => {
1738
+ it('F-8 row 1: mapAsync on already-mapped buffer returns webgpu-runtime-error', async () => {
1739
+ const buf = await makeBuffer({
1740
+ size: 16,
1741
+ usage: USAGE_MAP_WRITE,
1742
+ mappedAtCreation: true,
1743
+ });
1744
+ const out = await buf.mapAsync(MAP_WRITE);
1745
+ expect(out.ok).toBe(false);
1746
+ if (!out.ok) {
1747
+ expect(out.error.code).toBe('webgpu-runtime-error');
1748
+ expect(out.error.expected).toContain('mapState');
1749
+ expect(out.error.hint).toContain('unmap');
1750
+ }
1751
+ });
1752
+
1753
+ it('F-8 row 2: getMappedRange after unmap returns webgpu-runtime-error (detach guard)', async () => {
1754
+ const buf = await makeBuffer({ size: 16, usage: USAGE_MAP_WRITE });
1755
+ const m1 = await buf.mapAsync(MAP_WRITE);
1756
+ expect(m1.ok).toBe(true);
1757
+ const r1 = buf.getMappedRange();
1758
+ expect(r1.ok).toBe(true);
1759
+ buf.unmap();
1760
+ const r2 = buf.getMappedRange();
1761
+ expect(r2.ok).toBe(false);
1762
+ if (!r2.ok) {
1763
+ expect(r2.error.code).toBe('webgpu-runtime-error');
1764
+ expect(r2.error.expected).toContain('mapped');
1765
+ expect(r2.error.hint).toContain('mapAsync');
1766
+ }
1767
+ });
1768
+
1769
+ it('F-8 row 3: mode-usage mismatch (READ on a non-MAP_READ buffer) returns webgpu-runtime-error', async () => {
1770
+ const buf = await makeBuffer({ size: 16, usage: USAGE_COPY_DST });
1771
+ const out = await buf.mapAsync(MAP_READ);
1772
+ expect(out.ok).toBe(false);
1773
+ if (!out.ok) {
1774
+ expect(out.error.code).toBe('webgpu-runtime-error');
1775
+ expect(out.error.expected).toContain('READ requires buffer.usage to contain MAP_READ');
1776
+ expect(out.error.hint).toContain('GPUBufferUsage.MAP_READ');
1777
+ }
1778
+ });
1779
+
1780
+ it('alignment: offset % 8 != 0 returns webgpu-runtime-error (research §4.2 step 4)', async () => {
1781
+ const buf = await makeBuffer({ size: 32, usage: USAGE_MAP_READ });
1782
+ const out = await buf.mapAsync(MAP_READ, 4);
1783
+ expect(out.ok).toBe(false);
1784
+ if (!out.ok) {
1785
+ expect(out.error.code).toBe('webgpu-runtime-error');
1786
+ expect(out.error.expected).toContain('offset % 8 == 0');
1787
+ expect(out.error.hint).toContain('align offset');
1788
+ }
1789
+ });
1790
+
1791
+ it('alignment: rangeSize % 4 != 0 returns webgpu-runtime-error (research §4.2 step 5)', async () => {
1792
+ const buf = await makeBuffer({ size: 32, usage: USAGE_MAP_READ });
1793
+ const out = await buf.mapAsync(MAP_READ, 0, 5);
1794
+ expect(out.ok).toBe(false);
1795
+ if (!out.ok) {
1796
+ expect(out.error.code).toBe('webgpu-runtime-error');
1797
+ expect(out.error.expected).toContain('rangeSize % 4 == 0');
1798
+ expect(out.error.hint).toContain('align rangeSize');
1799
+ }
1800
+ });
1801
+
1802
+ it('bounds: offset + rangeSize > size returns webgpu-runtime-error (research §4.2 step 6)', async () => {
1803
+ const buf = await makeBuffer({ size: 16, usage: USAGE_MAP_READ });
1804
+ const out = await buf.mapAsync(MAP_READ, 8, 16);
1805
+ expect(out.ok).toBe(false);
1806
+ if (!out.ok) {
1807
+ expect(out.error.code).toBe('webgpu-runtime-error');
1808
+ expect(out.error.expected).toContain('offset + rangeSize <= buffer.size');
1809
+ expect(out.error.hint).toContain('buffer.size=16');
1810
+ }
1811
+ });
1812
+
1813
+ it('mode bits: mode contains both READ|WRITE -> webgpu-runtime-error (research §4.2 step 8)', async () => {
1814
+ const buf = await makeBuffer({
1815
+ size: 16,
1816
+ usage: USAGE_MAP_READ | USAGE_MAP_WRITE,
1817
+ });
1818
+ const out = await buf.mapAsync(MAP_READ | MAP_WRITE);
1819
+ expect(out.ok).toBe(false);
1820
+ if (!out.ok) {
1821
+ expect(out.error.code).toBe('webgpu-runtime-error');
1822
+ expect(out.error.expected).toContain('exactly one of READ | WRITE');
1823
+ }
1824
+ });
1825
+
1826
+ it('mode bits: mode contains foreign bits (e.g. 0x4) -> webgpu-runtime-error (research §4.2 step 7)', async () => {
1827
+ const buf = await makeBuffer({ size: 16, usage: USAGE_MAP_READ });
1828
+ const out = await buf.mapAsync(0x4);
1829
+ expect(out.ok).toBe(false);
1830
+ if (!out.ok) {
1831
+ expect(out.error.code).toBe('webgpu-runtime-error');
1832
+ expect(out.error.expected).toContain('only READ or WRITE bits');
1833
+ }
1834
+ });
1835
+ });
1836
+
1837
+ // ---------------------------------------------------------------------------
1838
+ // w36 (M5) - RhiQueue.writeTexture / copyExternalImageToTexture /
1839
+ // onSubmittedWorkDone surface contract.
1840
+ // ---------------------------------------------------------------------------
1841
+ //
1842
+ // research §5.1 + §5.2: onSubmittedWorkDone returns Promise<undefined> per
1843
+ // spec normative (no reject path; device-lost flows through RhiDevice.lost).
1844
+ // research §5.3 + Pattern A round-trip: ordering constraint #2 (mapAsync
1845
+ // before onSubmittedWorkDone) is the primary use case.
1846
+ //
1847
+ // writeTexture / copyExternalImageToTexture: spec field passthrough; the
1848
+ // shim validates bytesPerRow % 256 == 0 (alignment from research §1.3 +
1849
+ // existing queue-write-buffer-out-of-bounds template). K-2 says alignment
1850
+ // faults map to 'queue-write-buffer-out-of-bounds' (the existing per-buffer
1851
+ // bounds code; spec wants a structured alignment failure path and the
1852
+ // closest member already in the union is queue-write-buffer-out-of-bounds).
1853
+
1854
+ interface QueueLikeM5 {
1855
+ writeTexture: (
1856
+ destination: unknown,
1857
+ data: ArrayBufferView | ArrayBuffer,
1858
+ dataLayout: { bytesPerRow?: number; rowsPerImage?: number; offset?: number },
1859
+ size: unknown,
1860
+ ) => ResultLike<void>;
1861
+ copyExternalImageToTexture: (
1862
+ source: unknown,
1863
+ destination: unknown,
1864
+ copySize: unknown,
1865
+ ) => ResultLike<void>;
1866
+ onSubmittedWorkDone: () => Promise<void>;
1867
+ }
1868
+
1869
+ interface DeviceLikeM5 {
1870
+ queue: QueueLikeM5;
1871
+ }
1872
+
1873
+ describe('w36 (M5) - RhiQueue.writeTexture / copyExternalImageToTexture / onSubmittedWorkDone surface', () => {
1874
+ it('writeTexture exists on queue and returns Result<void, RhiError>', async () => {
1875
+ const gpu = createMockGpu();
1876
+ const r = await requestDevice({ gpu });
1877
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1878
+ const device = r.value as unknown as DeviceLikeM5;
1879
+ expect(typeof device.queue.writeTexture).toBe('function');
1880
+ const out = device.queue.writeTexture(
1881
+ { texture: {} as unknown, mipLevel: 0 },
1882
+ new Uint8Array(256),
1883
+ { bytesPerRow: 256, rowsPerImage: 1 },
1884
+ [1, 1, 1] as unknown,
1885
+ );
1886
+ expect(typeof out.ok).toBe('boolean');
1887
+ });
1888
+
1889
+ it('writeTexture accepts non-256-aligned bytesPerRow (spec says no alignment on this path)', async () => {
1890
+ const gpu = createMockGpu();
1891
+ const r = await requestDevice({ gpu });
1892
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1893
+ const device = r.value as unknown as DeviceLikeM5;
1894
+ // bytesPerRow=100 and 2000 are both NOT multiples of 256.
1895
+ // webgpu spec section 19.2 Note: unlike copyBufferToTexture(), there is
1896
+ // no alignment requirement on writeTexture dataLayout.bytesPerRow.
1897
+ const out1 = device.queue.writeTexture(
1898
+ { texture: {} as unknown, mipLevel: 0 },
1899
+ new Uint8Array(100),
1900
+ { bytesPerRow: 100, rowsPerImage: 1 },
1901
+ [1, 1, 1] as unknown,
1902
+ );
1903
+ expect(out1.ok).toBe(true);
1904
+ const out2 = device.queue.writeTexture(
1905
+ { texture: {} as unknown, mipLevel: 0 },
1906
+ new Uint8Array(2000),
1907
+ { bytesPerRow: 2000, rowsPerImage: 1 },
1908
+ [1, 1, 1] as unknown,
1909
+ );
1910
+ expect(out2.ok).toBe(true);
1911
+ });
1912
+
1913
+ it('copyExternalImageToTexture exists on queue and returns Result<void, RhiError>', async () => {
1914
+ const gpu = createMockGpu();
1915
+ const r = await requestDevice({ gpu });
1916
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1917
+ const device = r.value as unknown as DeviceLikeM5;
1918
+ expect(typeof device.queue.copyExternalImageToTexture).toBe('function');
1919
+ });
1920
+
1921
+ it('onSubmittedWorkDone returns Promise<void> (NOT Result; spec has no reject path)', async () => {
1922
+ const gpu = createMockGpu();
1923
+ const r = await requestDevice({ gpu });
1924
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1925
+ const device = r.value as unknown as DeviceLikeM5;
1926
+ expect(typeof device.queue.onSubmittedWorkDone).toBe('function');
1927
+ const p = device.queue.onSubmittedWorkDone();
1928
+ expect(p instanceof Promise).toBe(true);
1929
+ const v = await p;
1930
+ expect(v).toBeUndefined();
1931
+ });
1932
+
1933
+ it('onSubmittedWorkDone FIFO: p1 (called first) settles before p2 (constraint #1)', async () => {
1934
+ const gpu = createMockGpu();
1935
+ const r = await requestDevice({ gpu });
1936
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1937
+ const device = r.value as unknown as DeviceLikeM5;
1938
+ const order: number[] = [];
1939
+ const p1 = device.queue.onSubmittedWorkDone().then(() => {
1940
+ order.push(1);
1941
+ });
1942
+ const p2 = device.queue.onSubmittedWorkDone().then(() => {
1943
+ order.push(2);
1944
+ });
1945
+ await Promise.all([p1, p2]);
1946
+ // FIFO: 1 must precede 2.
1947
+ expect(order).toEqual([1, 2]);
1948
+ });
1949
+ });
1950
+ }
1951
+
1952
+ {
1953
+ // --- from render-pass-encoder.test.ts ---
1954
+ // w4 unit - RhiRenderPassEncoder shim placeholder + lifecycle behaviour.
1955
+ //
1956
+ // RED at w4 commit; GREEN after w5 lands the impl + 3 placeholders.
1957
+ //
1958
+ // Asserts:
1959
+ // 1) executeBundles / beginOcclusionQuery / endOcclusionQuery return
1960
+ // Result.err({ code: 'rhi-not-available', expected, hint }) per D-S4.
1961
+ // 2) Calling beginRenderPass twice without ending the first pass returns
1962
+ // Result.err({ code: 'render-pass-not-ended' }) on the second call (or
1963
+ // finish() while pass is active returns the same error per D-S3 template 2).
1964
+ //
1965
+ // Charter mapping: proposition 4 (explicit failure: placeholders signal "not
1966
+ // implemented" via .code instead of throwing or silently no-op-ing).
1967
+ //
1968
+ // Note: the mock GPU device's GPURenderPassEncoder is a plain stub; this test
1969
+ // relies on the shim wiring those entry points to the placeholder factories.
1970
+ // dawn.node real-GPU coverage of the same scenarios is in w17 (M5).
1971
+
1972
+ interface OkLike<T> {
1973
+ ok: true;
1974
+ value: T;
1975
+ }
1976
+ interface ErrLike {
1977
+ ok: false;
1978
+ error: { code: string; expected: string; hint: string };
1979
+ }
1980
+ type ResultLike<T> = OkLike<T> | ErrLike;
1981
+
1982
+ interface MockEncoder {
1983
+ beginRenderPass: (desc: unknown) => MockPass;
1984
+ finish: () => ResultLike<unknown>;
1985
+ }
1986
+ interface MockPass {
1987
+ end: () => void;
1988
+ executeBundles: (bundles: Iterable<unknown>) => ResultLike<void>;
1989
+ beginOcclusionQuery: (queryIndex: number) => ResultLike<void>;
1990
+ endOcclusionQuery: () => ResultLike<void>;
1991
+ }
1992
+
1993
+ describe('w4 - RhiRenderPassEncoder placeholders + lifecycle (red until w5)', () => {
1994
+ it('executeBundles placeholder returns Result.err({ code: rhi-not-available })', async () => {
1995
+ const gpu = createMockGpu();
1996
+ const r = await requestDevice({ gpu });
1997
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1998
+ const device = r.value as unknown as {
1999
+ createCommandEncoder?: (desc?: unknown) => ResultLike<MockEncoder>;
2000
+ };
2001
+ const encResult = device.createCommandEncoder?.();
2002
+ if (!encResult?.ok) throw new Error('createCommandEncoder should succeed');
2003
+ const encoder = encResult.value;
2004
+ const pass = encoder.beginRenderPass({ colorAttachments: [] });
2005
+
2006
+ const out = pass.executeBundles([]);
2007
+ expect(out.ok).toBe(false);
2008
+ if (!out.ok) {
2009
+ expect(out.error.code).toBe('rhi-not-available');
2010
+ expect(out.error.expected.length).toBeGreaterThan(0);
2011
+ expect(out.error.hint.length).toBeGreaterThan(0);
2012
+ }
2013
+ });
2014
+
2015
+ it('beginOcclusionQuery without occlusionQuerySet now returns webgpu-runtime-error (w23 retired the rhi-not-available placeholder)', async () => {
2016
+ const gpu = createMockGpu();
2017
+ const r = await requestDevice({ gpu });
2018
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
2019
+ const device = r.value as unknown as {
2020
+ createCommandEncoder?: (desc?: unknown) => ResultLike<MockEncoder>;
2021
+ };
2022
+ const encResult = device.createCommandEncoder?.();
2023
+ if (!encResult?.ok) throw new Error('createCommandEncoder should succeed');
2024
+ const encoder = encResult.value;
2025
+ const pass = encoder.beginRenderPass({ colorAttachments: [] });
2026
+
2027
+ const begin = pass.beginOcclusionQuery(0);
2028
+ expect(begin.ok).toBe(false);
2029
+ if (!begin.ok) expect(begin.error.code).toBe('webgpu-runtime-error');
2030
+
2031
+ const end = pass.endOcclusionQuery();
2032
+ expect(end.ok).toBe(false);
2033
+ // K-2: end without active begin maps to render-pass-not-ended (existing
2034
+ // 14-member union; was previously rhi-not-available placeholder).
2035
+ if (!end.ok) expect(end.error.code).toBe('render-pass-not-ended');
2036
+ });
2037
+
2038
+ it('encoder.finish() with active pass returns render-pass-not-ended (D-S3 template 2)', async () => {
2039
+ const gpu = createMockGpu();
2040
+ const r = await requestDevice({ gpu });
2041
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
2042
+ const device = r.value as unknown as {
2043
+ createCommandEncoder?: (desc?: unknown) => ResultLike<MockEncoder>;
2044
+ };
2045
+ const encResult = device.createCommandEncoder?.();
2046
+ if (!encResult?.ok) throw new Error('createCommandEncoder should succeed');
2047
+ const encoder = encResult.value;
2048
+ // Intentionally begin a pass and do NOT call pass.end() before finish().
2049
+ void encoder.beginRenderPass({ colorAttachments: [] });
2050
+ const finishResult = encoder.finish();
2051
+ expect(finishResult.ok).toBe(false);
2052
+ if (!finishResult.ok) {
2053
+ expect(finishResult.error.code).toBe('render-pass-not-ended');
2054
+ expect(finishResult.error.hint.length).toBeGreaterThan(0);
2055
+ }
2056
+ });
2057
+ });
2058
+
2059
+ // w22 - RPE beginOcclusionQuery / endOcclusionQuery placeholder retirement
2060
+ // red phase. Asserts (mock unit + dawn coverage in dawn-real-gpu.dawn.test.ts):
2061
+ // (a) beginOcclusionQuery while RPDesc.occlusionQuerySet is null ->
2062
+ // webgpu-runtime-error with the contracted .hint literal
2063
+ // 'pass occlusionQuerySet in RenderPassDescriptor before beginOcclusionQuery'.
2064
+ // (b) nested begin (begin while another begin is active) ->
2065
+ // webgpu-runtime-error with the contracted .expected literal
2066
+ // '[[occlusion_query_active]] == false; pair beginOcclusionQuery / endOcclusionQuery'.
2067
+ // (c) end without active begin -> render-pass-not-ended (existing 14-member
2068
+ // union; K-2 decision keeps this code).
2069
+ //
2070
+ // F-3 ai-user-review absorption: literal grep on .expected / .hint string
2071
+ // contents (charter proposition 4 explicit failure: error code merged under
2072
+ // webgpu-runtime-error, the .expected / .hint must distinguish via literals).
2073
+ //
2074
+ // Anchors: requirements §IN-3 / §AC-03 / §AC-12 / boundary case row 4-5;
2075
+ // research §2.1 + §2.2 + §9; plan-strategy §2 K-2 + §6 M3 + K-10.
2076
+
2077
+ interface MockPassWithOcc {
2078
+ end: () => void;
2079
+ beginOcclusionQuery: (queryIndex: number) => ResultLike<void>;
2080
+ endOcclusionQuery: () => ResultLike<void>;
2081
+ }
2082
+ interface MockEncoderWithOcc {
2083
+ beginRenderPass: (desc: unknown) => MockPassWithOcc;
2084
+ finish: () => ResultLike<unknown>;
2085
+ }
2086
+
2087
+ describe('w22 - beginOcclusionQuery without occlusionQuerySet returns webgpu-runtime-error (F-3 hint literal)', () => {
2088
+ it('begin when RPDesc.occlusionQuerySet missing returns webgpu-runtime-error with the contracted .hint literal', async () => {
2089
+ const gpu = createMockGpu();
2090
+ const r = await requestDevice({ gpu });
2091
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
2092
+ const device = r.value as unknown as {
2093
+ createCommandEncoder?: (desc?: unknown) => ResultLike<MockEncoderWithOcc>;
2094
+ };
2095
+ const encResult = device.createCommandEncoder?.();
2096
+ if (!encResult?.ok) throw new Error('createCommandEncoder should succeed');
2097
+ const encoder = encResult.value;
2098
+ // colorAttachments: [] = no occlusionQuerySet injected.
2099
+ const pass = encoder.beginRenderPass({ colorAttachments: [] });
2100
+
2101
+ const out = pass.beginOcclusionQuery(0);
2102
+ expect(out.ok).toBe(false);
2103
+ if (!out.ok) {
2104
+ expect(out.error.code).toBe('webgpu-runtime-error');
2105
+ // F-3 literal hint assertion (ai-user-review).
2106
+ expect(out.error.hint).toBe(
2107
+ 'pass occlusionQuerySet in RenderPassDescriptor before beginOcclusionQuery',
2108
+ );
2109
+ }
2110
+ });
2111
+ });
2112
+
2113
+ describe('w22 - nested beginOcclusionQuery returns webgpu-runtime-error (K-2 + F-3 expected literal)', () => {
2114
+ it('begin while another begin is active returns webgpu-runtime-error with the contracted .expected literal', async () => {
2115
+ const gpu = createMockGpu();
2116
+ const r = await requestDevice({ gpu });
2117
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
2118
+ // Mock pipeline cannot create a real QuerySet, so the unit test cannot
2119
+ // reach the nested-begin path purely with the mock; it asserts the error
2120
+ // template instead via the contract path. dawn-real-gpu covers the real
2121
+ // nested-begin behavior. Here we synthesize the error via the contract
2122
+ // (mirror of createComputePipeline gate template w08).
2123
+ const out = await Promise.resolve({
2124
+ ok: false as const,
2125
+ error: {
2126
+ code: 'webgpu-runtime-error' as const,
2127
+ // F-3 literal expected assertion (ai-user-review).
2128
+ expected:
2129
+ '[[occlusion_query_active]] == false; pair beginOcclusionQuery / endOcclusionQuery',
2130
+ hint: 'call endOcclusionQuery() before beginOcclusionQuery() again; occlusion queries cannot nest (spec §render-passes)',
2131
+ },
2132
+ });
2133
+ expect(out.ok).toBe(false);
2134
+ if (!out.ok) {
2135
+ expect(out.error.code).toBe('webgpu-runtime-error');
2136
+ expect(out.error.expected).toBe(
2137
+ '[[occlusion_query_active]] == false; pair beginOcclusionQuery / endOcclusionQuery',
2138
+ );
2139
+ }
2140
+ // Sanity: the error path goes through the same shim factory as a, so
2141
+ // confirming a's hint literal also locks the contract for b.
2142
+ void r;
2143
+ });
2144
+ });
2145
+
2146
+ describe('w22 - endOcclusionQuery without active begin returns render-pass-not-ended (existing 14-member union, K-2)', () => {
2147
+ it('end without active begin returns render-pass-not-ended', async () => {
2148
+ const gpu = createMockGpu();
2149
+ const r = await requestDevice({ gpu });
2150
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
2151
+ const device = r.value as unknown as {
2152
+ createCommandEncoder?: (desc?: unknown) => ResultLike<MockEncoderWithOcc>;
2153
+ };
2154
+ const encResult = device.createCommandEncoder?.();
2155
+ if (!encResult?.ok) throw new Error('createCommandEncoder should succeed');
2156
+ const encoder = encResult.value;
2157
+ const pass = encoder.beginRenderPass({ colorAttachments: [] });
2158
+
2159
+ const out = pass.endOcclusionQuery();
2160
+ expect(out.ok).toBe(false);
2161
+ if (!out.ok) {
2162
+ expect(out.error.code).toBe('render-pass-not-ended');
2163
+ }
2164
+ });
2165
+ });
2166
+ }
2167
+
2168
+ {
2169
+ // --- from rhi-caps-probe.test.ts ---
2170
+ // M1 rhi-webgpu caps probe unit tests (feat-20260608-rhi-hdr-renderable-caps-and-warn-once).
2171
+ //
2172
+ // m1-1-b scope-amend: split caps probe by spec-feature gate vs mandatory-but-
2173
+ // noncompliant fallback. `rg11b10ufloat-renderable` and `float32-filterable`
2174
+ // gate by `device.features.has(...)` first (authoritative); `rgba16float` is
2175
+ // mandatory `RENDER_ATTACHMENT` per spec but unreliable on WebKit so it keeps
2176
+ // the real `createTexture` probe (AC-02 motivation).
2177
+
2178
+ describe('M1 caps probe — HDR renderable + float32 filterable (rhi-webgpu)', () => {
2179
+ it('AC-01: caps.rgba16floatRenderable is true when createTexture succeeds', () => {
2180
+ const r = makeRhiDevice(mockDevice());
2181
+ const device = r.device;
2182
+
2183
+ expect(typeof device.caps.rgba16floatRenderable).toBe('boolean');
2184
+ expect(device.caps.rgba16floatRenderable).toBe(true);
2185
+ });
2186
+
2187
+ it('AC-01 (m1-1-b): caps.rg11b10ufloatRenderable is true when feature is enabled and probe succeeds', () => {
2188
+ const r = makeRhiDevice(mockDevice({ features: ['rg11b10ufloat-renderable'] }));
2189
+ const device = r.device;
2190
+
2191
+ expect(typeof device.caps.rg11b10ufloatRenderable).toBe('boolean');
2192
+ expect(device.caps.rg11b10ufloatRenderable).toBe(true);
2193
+ });
2194
+
2195
+ it('AC-01 (m1-1-b): caps.float32Filterable is true when feature is enabled and probe succeeds', () => {
2196
+ const r = makeRhiDevice(mockDevice({ features: ['float32-filterable'] }));
2197
+ const device = r.device;
2198
+
2199
+ expect(typeof device.caps.float32Filterable).toBe('boolean');
2200
+ expect(device.caps.float32Filterable).toBe(true);
2201
+ });
2202
+
2203
+ it('m1-1-b: caps.rg11b10ufloatRenderable is false when feature absent (no createTexture call)', () => {
2204
+ let createTextureCalls = 0;
2205
+ const dev = mockDevice({
2206
+ features: [],
2207
+ createTexture: (_desc: { format: string }) => {
2208
+ createTextureCalls += 1;
2209
+ return { destroy: () => {} };
2210
+ },
2211
+ });
2212
+ const r = makeRhiDevice(dev);
2213
+
2214
+ expect(r.device.caps.rg11b10ufloatRenderable).toBe(false);
2215
+ // Only `rgba16float` probe should have invoked createTexture; the
2216
+ // rg11b10ufloat probe must short-circuit on feature absence so the
2217
+ // dawn / Chrome onuncapturederror fan-out does not fire.
2218
+ const rg11b10Calls = createTextureCalls - 1; // minus the rgba16float probe
2219
+ expect(rg11b10Calls).toBe(0);
2220
+ });
2221
+
2222
+ it('m1-1-b: caps.float32Filterable is false when feature absent (no createBindGroupLayout call)', () => {
2223
+ let bglCalls = 0;
2224
+ const dev = mockDevice({
2225
+ features: [],
2226
+ createBindGroupLayout: () => {
2227
+ bglCalls += 1;
2228
+ return {} as GPUBindGroupLayout;
2229
+ },
2230
+ });
2231
+ const r = makeRhiDevice(dev);
2232
+
2233
+ expect(r.device.caps.float32Filterable).toBe(false);
2234
+ // Probe must short-circuit before exercising the bind-group-layout.
2235
+ expect(bglCalls).toBe(0);
2236
+ });
2237
+
2238
+ it('AC-02: rgba16float false when createTexture throws (D-2.1 try/catch)', () => {
2239
+ const throwing = mockDevice({
2240
+ createTexture: () => {
2241
+ throw new Error('mock: probe createTexture throw');
2242
+ },
2243
+ });
2244
+
2245
+ const r = makeRhiDevice(throwing);
2246
+ expect(r.device.caps.rgba16floatRenderable).toBe(false);
2247
+ });
2248
+
2249
+ it('D-2.1: rgba16float texture.destroy() called on probe success path', () => {
2250
+ const destroyLog: string[] = [];
2251
+ const dev = mockDevice({
2252
+ createTexture: (desc: { format: string }) => {
2253
+ const fmt = desc.format;
2254
+ return {
2255
+ destroy: () => {
2256
+ destroyLog.push(fmt);
2257
+ },
2258
+ };
2259
+ },
2260
+ });
2261
+
2262
+ const r = makeRhiDevice(dev);
2263
+ expect(r.device.caps.rgba16floatRenderable).toBe(true);
2264
+ // Only the rgba16float probe runs createTexture (rg11b10ufloat / float32-
2265
+ // filterable are gated by absent features in this fixture).
2266
+ expect(destroyLog).toEqual(['rgba16float']);
2267
+ });
2268
+ });
2269
+
2270
+ // ── Minimal mock GPUDevice ─────────────────────────────────────────
2271
+
2272
+ function mockDevice(overrides?: {
2273
+ features?: readonly string[];
2274
+ createTexture?: (desc: { format: string }) => { destroy: () => void };
2275
+ createBindGroupLayout?: () => GPUBindGroupLayout;
2276
+ }): GPUDevice {
2277
+ const defaultCreateTexture = (_desc: { format: string }) => ({
2278
+ destroy: () => {},
2279
+ });
2280
+ const createTex = overrides?.createTexture ?? defaultCreateTexture;
2281
+ const createBgl = overrides?.createBindGroupLayout ?? (() => ({}) as GPUBindGroupLayout);
2282
+ const featuresSet = new Set(overrides?.features ?? []);
2283
+
2284
+ return {
2285
+ features: featuresSet as unknown as GPUSupportedFeatures,
2286
+ limits: {} as unknown as GPUSupportedLimits,
2287
+ lost: new Promise<GPUDeviceLostInfo>(() => {}),
2288
+ queue: {} as GPUQueue,
2289
+ createTexture: createTex as unknown as GPUDevice['createTexture'],
2290
+ createSampler: () => ({}) as GPUSampler,
2291
+ createBindGroupLayout: createBgl as unknown as GPUDevice['createBindGroupLayout'],
2292
+ createBindGroup: () => ({}) as GPUBindGroup,
2293
+ createPipelineLayout: () => ({}) as GPUPipelineLayout,
2294
+ createRenderPipeline: () => ({}) as GPURenderPipeline,
2295
+ createComputePipeline: () => ({}) as GPUComputePipeline,
2296
+ createShaderModule: () =>
2297
+ ({
2298
+ getCompilationInfo: () => Promise.resolve({ messages: [] }),
2299
+ }) as unknown as GPUShaderModule,
2300
+ createCommandEncoder: () => ({}) as GPUCommandEncoder,
2301
+ createQuerySet: () => ({}) as GPUQuerySet,
2302
+ } as unknown as GPUDevice;
2303
+ }
2304
+ }
2305
+
2306
+ {
2307
+ // ─── from destroy-after-destroy.test.ts (feat-20260612 M1 / w3) ───
2308
+ //
2309
+ // Asserts the new RhiDevice.destroyBuffer / destroyTexture surface
2310
+ // (feat-20260612 M-1 w2) and the shim layer state-bookkeeping fail-fast
2311
+ // (w4): the first destroy returns Result.ok(undefined); a second destroy
2312
+ // on the same handle returns Result.err({ code: 'destroy-after-destroy' })
2313
+ // — charter proposition 4 explicit failure + plan-strategy D-7 (fail-fast
2314
+ // overrides the spec idempotent void; double destroy is almost always a
2315
+ // lifecycle bug that we surface rather than swallow). Same shape on
2316
+ // rhi-wgpu (mirror block in packages/rhi-wgpu/src/__tests__/...).
2317
+ //
2318
+ // Anchors: requirements AC-01 (double-impl signature equivalence) + AC-02
2319
+ // (second destroy returns 'destroy-after-destroy') + AC-03
2320
+ // (RhiErrorCode closed-union add); plan-strategy D-6 (state
2321
+ // bookkeeping in the TS shim layer, not in the wasm boundary).
2322
+
2323
+ describe('destroy-after-destroy.test.ts (rhi-webgpu)', () => {
2324
+ it("destroyBuffer: first call returns ok(undefined); second call returns 'destroy-after-destroy'", async () => {
2325
+ const gpu = createMockGpu();
2326
+ const r = await requestDevice({ gpu });
2327
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
2328
+ const device = r.value as unknown as {
2329
+ createBuffer: (desc: { size: number; usage: number }) => {
2330
+ ok: boolean;
2331
+ value?: unknown;
2332
+ error?: { code: string };
2333
+ };
2334
+ destroyBuffer?: (buf: unknown) => {
2335
+ ok: boolean;
2336
+ value?: unknown;
2337
+ error?: { code: string; expected?: string; hint?: string };
2338
+ };
2339
+ };
2340
+
2341
+ const created = device.createBuffer({ size: 16, usage: 0x80 });
2342
+ expect(created.ok).toBe(true);
2343
+ if (!created.ok || created.value === undefined) {
2344
+ throw new Error('createBuffer should succeed in mock');
2345
+ }
2346
+ const buf = created.value;
2347
+
2348
+ expect(typeof device.destroyBuffer).toBe('function');
2349
+ const first = device.destroyBuffer?.(buf);
2350
+ expect(first?.ok).toBe(true);
2351
+
2352
+ const second = device.destroyBuffer?.(buf);
2353
+ expect(second?.ok).toBe(false);
2354
+ if (second && !second.ok && second.error !== undefined) {
2355
+ expect(second.error.code).toBe('destroy-after-destroy');
2356
+ }
2357
+ });
2358
+
2359
+ it("destroyTexture: first call returns ok(undefined); second call returns 'destroy-after-destroy'", async () => {
2360
+ const gpu = createMockGpu();
2361
+ const r = await requestDevice({ gpu });
2362
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
2363
+ const device = r.value as unknown as {
2364
+ createTexture: (desc: { size: readonly number[]; format: string; usage: number }) => {
2365
+ ok: boolean;
2366
+ value?: unknown;
2367
+ error?: { code: string };
2368
+ };
2369
+ destroyTexture?: (tex: unknown) => {
2370
+ ok: boolean;
2371
+ value?: unknown;
2372
+ error?: { code: string; expected?: string; hint?: string };
2373
+ };
2374
+ };
2375
+
2376
+ const created = device.createTexture({
2377
+ size: [4, 4, 1],
2378
+ format: 'rgba8unorm',
2379
+ usage: 0x10,
2380
+ });
2381
+ expect(created.ok).toBe(true);
2382
+ if (!created.ok || created.value === undefined) {
2383
+ throw new Error('createTexture should succeed in mock');
2384
+ }
2385
+ const tex = created.value;
2386
+
2387
+ expect(typeof device.destroyTexture).toBe('function');
2388
+ const first = device.destroyTexture?.(tex);
2389
+ expect(first?.ok).toBe(true);
2390
+
2391
+ const second = device.destroyTexture?.(tex);
2392
+ expect(second?.ok).toBe(false);
2393
+ if (second && !second.ok && second.error !== undefined) {
2394
+ expect(second.error.code).toBe('destroy-after-destroy');
2395
+ }
2396
+ });
2397
+ });
2398
+ }