@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.
- package/LICENSE +202 -0
- package/README.md +133 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/__mocks__/gpu-device.d.ts +226 -0
- package/dist/__tests__/__mocks__/gpu-device.d.ts.map +1 -0
- package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts +2 -0
- package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-webgpu.unit.test.d.ts +2 -0
- package/dist/__tests__/rhi-webgpu.unit.test.d.ts.map +1 -0
- package/dist/device.d.ts +62 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/errors.d.ts +49 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +183 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1745 -0
- package/dist/index.mjs.map +1 -0
- package/dist/internal/__tests__/timestamp-query.unit.test.d.ts +2 -0
- package/dist/internal/__tests__/timestamp-query.unit.test.d.ts.map +1 -0
- package/dist/internal/error-translation.d.ts +16 -0
- package/dist/internal/error-translation.d.ts.map +1 -0
- package/dist/internal/timestamp-query.d.ts +15 -0
- package/dist/internal/timestamp-query.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/__tests__/__mocks__/gpu-device.ts +555 -0
- package/src/__tests__/dawn-real-gpu.dawn.test.ts +1445 -0
- package/src/__tests__/rhi-webgpu.unit.test.ts +2398 -0
- package/src/device.ts +2102 -0
- package/src/errors.ts +183 -0
- package/src/index.ts +597 -0
- package/src/internal/__tests__/timestamp-query.unit.test.ts +88 -0
- package/src/internal/error-translation.ts +187 -0
- 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
|
+
}
|