@forgeax/engine-render-graph 0.0.0-dev.8d955ade1c79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +184 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/observation.unit.test.d.ts +2 -0
  5. package/dist/__tests__/observation.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/render-graph-alias-source.unit.test.d.ts +2 -0
  7. package/dist/__tests__/render-graph-alias-source.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/render-graph-builder.unit.test.d.ts +2 -0
  9. package/dist/__tests__/render-graph-builder.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/render-graph-errors.test-d.d.ts +2 -0
  11. package/dist/__tests__/render-graph-errors.test-d.d.ts.map +1 -0
  12. package/dist/__tests__/render-graph-regressions.unit.test.d.ts +2 -0
  13. package/dist/__tests__/render-graph-regressions.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/render-graph-rhi-null.integration.test.d.ts +2 -0
  15. package/dist/__tests__/render-graph-rhi-null.integration.test.d.ts.map +1 -0
  16. package/dist/__tests__/render-graph.unit.test.d.ts +2 -0
  17. package/dist/__tests__/render-graph.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/resource-declaration-collision.test.d.ts +2 -0
  19. package/dist/__tests__/resource-declaration-collision.test.d.ts.map +1 -0
  20. package/dist/builder.d.ts +42 -0
  21. package/dist/builder.d.ts.map +1 -0
  22. package/dist/compiled-graph.d.ts +23 -0
  23. package/dist/compiled-graph.d.ts.map +1 -0
  24. package/dist/errors.d.ts +148 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/graph.d.ts +403 -0
  27. package/dist/graph.d.ts.map +1 -0
  28. package/dist/index.d.ts +8 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.mjs +2256 -0
  31. package/dist/index.mjs.map +1 -0
  32. package/dist/kernel-internal.d.ts +84 -0
  33. package/dist/kernel-internal.d.ts.map +1 -0
  34. package/dist/observation.d.ts +38 -0
  35. package/dist/observation.d.ts.map +1 -0
  36. package/dist/pass-registry.d.ts +12 -0
  37. package/dist/pass-registry.d.ts.map +1 -0
  38. package/dist/pipeline/__tests__/color-value-domain.test.d.ts +2 -0
  39. package/dist/pipeline/__tests__/color-value-domain.test.d.ts.map +1 -0
  40. package/dist/pipeline/color-value-domain.d.ts +35 -0
  41. package/dist/pipeline/color-value-domain.d.ts.map +1 -0
  42. package/dist/resource-registry.d.ts +62 -0
  43. package/dist/resource-registry.d.ts.map +1 -0
  44. package/dist/types.d.ts +171 -0
  45. package/dist/types.d.ts.map +1 -0
  46. package/package.json +61 -0
  47. package/src/__tests__/observation.unit.test.ts +103 -0
  48. package/src/__tests__/render-graph-alias-source.unit.test.ts +118 -0
  49. package/src/__tests__/render-graph-builder.unit.test.ts +673 -0
  50. package/src/__tests__/render-graph-errors.test-d.ts +207 -0
  51. package/src/__tests__/render-graph-regressions.unit.test.ts +126 -0
  52. package/src/__tests__/render-graph-rhi-null.integration.test.ts +86 -0
  53. package/src/__tests__/render-graph.unit.test.ts +2551 -0
  54. package/src/__tests__/resource-declaration-collision.test.ts +151 -0
  55. package/src/builder.ts +1009 -0
  56. package/src/compiled-graph.ts +383 -0
  57. package/src/errors.ts +205 -0
  58. package/src/graph.ts +1269 -0
  59. package/src/index.ts +97 -0
  60. package/src/kernel-internal.ts +148 -0
  61. package/src/observation.ts +134 -0
  62. package/src/pass-registry.ts +43 -0
  63. package/src/pipeline/__tests__/color-value-domain.test.ts +43 -0
  64. package/src/pipeline/color-value-domain.ts +139 -0
  65. package/src/resource-registry.ts +174 -0
  66. package/src/types.ts +216 -0
@@ -0,0 +1,2551 @@
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=7):
5
+ // - packages/render-graph/src/__tests__/pass-order.test.ts
6
+ // - packages/render-graph/src/__tests__/bloom-topo.test.ts
7
+ // - packages/render-graph/src/__tests__/compile-fail-fast.test.ts
8
+ // - packages/render-graph/src/__tests__/errors.test.ts
9
+ // - packages/render-graph/src/__tests__/list-passes-resources.test.ts
10
+ // - packages/render-graph/src/__tests__/resource-allocation.test.ts
11
+ // - packages/render-graph/src/__tests__/uniform-fallback.test.ts
12
+ //
13
+ // Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
14
+ // source as ancestorTitles[0]. Top-level imports merged + deduped.
15
+
16
+ import { describe, expect, it } from 'vitest';
17
+ import type { CapMissingDetail, DanglingReadDetail, DuplicateResourceDetail } from '../errors.js';
18
+ import { err, ok, RenderGraphError, type RenderGraphErrorCode } from '../errors.js';
19
+ import { RenderGraph } from '../graph.js';
20
+
21
+ {
22
+ describe('color domain compile contract', () => {
23
+ const caps = { compute: true, storageBuffer: true } as never;
24
+
25
+ function graphWithTargets(sourceDomain: unknown, destinationDomain: unknown): RenderGraph {
26
+ const graph = new RenderGraph();
27
+ graph.addColorTarget('source', {
28
+ format: 'rgba16float',
29
+ size: { w: 1, h: 1 },
30
+ domain: sourceDomain,
31
+ } as never);
32
+ graph.addColorTarget('destination', {
33
+ format: 'rgba8unorm',
34
+ size: { w: 1, h: 1 },
35
+ domain: destinationDomain,
36
+ } as never);
37
+ graph.addPass('source-seed', { reads: [], writes: ['source'] });
38
+ return graph;
39
+ }
40
+
41
+ it('rejects a domain connection when the source domain is unknown', () => {
42
+ const graph = graphWithTargets('gamma-magic', 'linear-ldr');
43
+ graph.addPass('blend', {
44
+ reads: ['source'],
45
+ writes: ['destination'],
46
+ colorConnections: [{ source: 'source', destination: 'destination' }],
47
+ } as never);
48
+
49
+ const result = graph.compile({ backendKind: 'null', caps });
50
+ expect(result.ok).toBe(false);
51
+ if (result.ok) throw new Error('expected invalid color domain');
52
+ expect(result.error.code).toBe('invalid-color-domain');
53
+ });
54
+
55
+ it('rejects a connection whose destination domain is missing', () => {
56
+ const graph = graphWithTargets('linear-ldr', undefined);
57
+ graph.addPass('blend', {
58
+ reads: ['source'],
59
+ writes: ['destination'],
60
+ colorConnections: [{ source: 'source', destination: 'destination' }],
61
+ } as never);
62
+
63
+ const result = graph.compile({ backendKind: 'null', caps });
64
+ expect(result.ok).toBe(false);
65
+ if (result.ok) throw new Error('expected missing color domain');
66
+ expect(result.error.code).toBe('missing-color-domain');
67
+ expect(result.error.hint).toContain('domain');
68
+ });
69
+
70
+ it('rejects an implicit linear-to-encoded connection', () => {
71
+ const graph = graphWithTargets('linear-ldr', 'display-encoded');
72
+ graph.addPass('blend', {
73
+ reads: ['source'],
74
+ writes: ['destination'],
75
+ colorConnections: [{ source: 'source', destination: 'destination' }],
76
+ } as never);
77
+
78
+ const result = graph.compile({ backendKind: 'null', caps });
79
+ expect(result.ok).toBe(false);
80
+ if (result.ok) throw new Error('expected a domain mismatch');
81
+ expect(result.error.code).toBe('color-domain-mismatch');
82
+ expect(result.error.expected).toContain('explicit');
83
+ });
84
+
85
+ it('accepts an explicit output encoding connection', () => {
86
+ const graph = graphWithTargets('linear-ldr', 'display-encoded');
87
+ graph.addPass('encode', {
88
+ reads: ['source'],
89
+ writes: ['destination'],
90
+ colorConnections: [
91
+ {
92
+ source: 'source',
93
+ destination: 'destination',
94
+ conversion: { kind: 'encode-srgb' },
95
+ },
96
+ ],
97
+ } as never);
98
+
99
+ const result = graph.compile({ backendKind: 'null', caps });
100
+ expect(result.ok).toBe(true);
101
+ });
102
+ });
103
+
104
+ describe('whole-graph retirement', () => {
105
+ it('relinquishes transient and persistent targets before GPU completion, then reclaims both', async () => {
106
+ const destroyed: object[] = [];
107
+ let resolveSubmittedWork: (() => void) | undefined;
108
+ const submittedWork = new Promise<void>((resolve) => {
109
+ resolveSubmittedWork = resolve;
110
+ });
111
+ const device = {
112
+ createTexture: () => ({ ok: true as const, value: { id: 'transient' } }),
113
+ createTextureView: () => ({ ok: true as const, value: { id: 'view' } }),
114
+ destroyTexture: (texture: object) => {
115
+ destroyed.push(texture);
116
+ return { ok: true as const, value: undefined };
117
+ },
118
+ queue: { onSubmittedWorkDone: () => submittedWork },
119
+ };
120
+ const g = new RenderGraph();
121
+ g.addColorTarget('transient', {
122
+ format: 'rgba16float',
123
+ size: 'swapchain',
124
+ sample: 1,
125
+ usage: 0,
126
+ });
127
+ g.addPass('main', { reads: [], writes: ['transient'] });
128
+ const compiled = g.compile({
129
+ backendKind: 'webgpu',
130
+ caps: {
131
+ backendKind: 'webgpu',
132
+ compute: true,
133
+ storageBuffer: true,
134
+ storageTexture: false,
135
+ timestampQuery: false,
136
+ timestampPeriodNanoseconds: null,
137
+ indirectDrawing: false,
138
+ textureCompressionBc: false,
139
+ textureCompressionEtc2: false,
140
+ textureCompressionAstc: false,
141
+ multiDrawIndirect: false,
142
+ pushConstants: false,
143
+ textureBindingArray: false,
144
+ samplerAliasing: true,
145
+ firstInstanceIndirect: false,
146
+ rgba16floatRenderable: false,
147
+ rg11b10ufloatRenderable: false,
148
+ float32Filterable: false,
149
+ maxColorAttachments: 8,
150
+ },
151
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
152
+ device: device as any,
153
+ });
154
+ expect(compiled.ok).toBe(true);
155
+
156
+ const persistentTexture = { id: 'persistent' };
157
+ const graphInternals = g as unknown as {
158
+ persistentTextures: Map<string, { texture: object; view: object }>;
159
+ transientPool: Map<string, { texture: object; view: object }>;
160
+ retire?: () => void;
161
+ };
162
+ graphInternals.persistentTextures.set('persistent', {
163
+ texture: persistentTexture,
164
+ view: { id: 'persistent-view' },
165
+ });
166
+
167
+ expect(graphInternals.retire).toBeTypeOf('function');
168
+ if (graphInternals.retire === undefined) return;
169
+ graphInternals.retire();
170
+
171
+ expect(graphInternals.transientPool).toHaveLength(0);
172
+ expect(graphInternals.persistentTextures).toHaveLength(0);
173
+ expect(destroyed).toEqual([]);
174
+
175
+ const reclaimed = g.reclaimRetiredTransients();
176
+ await Promise.resolve();
177
+ expect(destroyed).toEqual([]);
178
+ resolveSubmittedWork?.();
179
+ await reclaimed;
180
+
181
+ expect(destroyed).toHaveLength(2);
182
+ expect(destroyed).toContain(persistentTexture);
183
+ });
184
+ });
185
+ }
186
+
187
+ {
188
+ // --- from pass-order.test.ts ---
189
+ // ── Helpers ──────────────────────────────────────────────────────
190
+
191
+ function mockCaps() {
192
+ return {
193
+ backendKind: 'webgpu' as const,
194
+ compute: true,
195
+ storageBuffer: true,
196
+ storageTexture: false,
197
+ timestampQuery: false,
198
+ timestampPeriodNanoseconds: null,
199
+ indirectDrawing: false,
200
+ textureCompressionBc: false,
201
+ textureCompressionEtc2: false,
202
+ textureCompressionAstc: false,
203
+ multiDrawIndirect: false,
204
+ pushConstants: false,
205
+ textureBindingArray: false,
206
+ samplerAliasing: true,
207
+ firstInstanceIndirect: false,
208
+ rgba16floatRenderable: false,
209
+ rg11b10ufloatRenderable: false,
210
+ float32Filterable: false,
211
+ maxColorAttachments: 8,
212
+ };
213
+ }
214
+
215
+ // ── Per-frame pass order ─────────────────────────────────────────
216
+ //
217
+ // feat-20260529-rendergraph-pass-abstraction M4 / w14. Mirrors the EXACT
218
+ // reads/writes the runtime declares for the per-frame passes so the
219
+ // integration-level declaration order is asserted on the real shape:
220
+ // shadow : reads [] writes ['shadowDepth']
221
+ // main : reads ['shadowDepth'] writes ['hdrColor', 'depth']
222
+ // tonemap : reads ['hdrColor'] writes []
223
+ // fxaa : reads [] writes ['fxaaIntermediate']
224
+
225
+ function buildPerFrameGraph() {
226
+ const g = new RenderGraph();
227
+ g.addResource('depth', { kind: 'texture', lifetime: 'persistent' });
228
+ g.addResource('shadowDepth', { kind: 'texture', lifetime: 'persistent' });
229
+ g.addResource('fxaaIntermediate', { kind: 'texture', lifetime: 'transient' });
230
+ g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
231
+ g.addPass('shadow', { reads: [], writes: ['shadowDepth'] });
232
+ g.addPass('main', { reads: ['shadowDepth'], writes: ['hdrColor', 'depth'] });
233
+ g.addPass('tonemap', { reads: ['hdrColor'], writes: [] });
234
+ g.addPass('fxaa', { reads: [], writes: ['fxaaIntermediate'] });
235
+ return g;
236
+ }
237
+
238
+ describe('per-frame pass order', () => {
239
+ it('executes compiled pass closures in declaration order', () => {
240
+ const calls: string[] = [];
241
+ const g = new RenderGraph<{ readonly frame: number }>();
242
+ g.addResource('X', { kind: 'texture', lifetime: 'transient' });
243
+ g.addPass('producer', {
244
+ reads: [],
245
+ writes: ['X'],
246
+ execute: () => calls.push('producer'),
247
+ });
248
+ g.addPass('consumer', {
249
+ reads: ['X'],
250
+ writes: [],
251
+ execute: () => calls.push('consumer'),
252
+ });
253
+
254
+ const compiled = g.compile({ backendKind: 'webgpu', caps: mockCaps() });
255
+ expect(compiled.ok).toBe(true);
256
+ const executedPasses: string[] = [];
257
+ g.execute({ frame: 1 }, (name, run) => {
258
+ executedPasses.push(name);
259
+ run();
260
+ });
261
+
262
+ expect(calls).toEqual(['producer', 'consumer']);
263
+ expect(executedPasses).toEqual(['producer', 'consumer']);
264
+ });
265
+
266
+ it('preserves shadow -> main pass ordering (shadow before main in compiled order)', () => {
267
+ const r = buildPerFrameGraph().compile({ backendKind: 'wgpu-native', caps: mockCaps() });
268
+ expect(r.ok).toBe(true);
269
+ if (!r.ok) return;
270
+ const order = r.value.passes.map((p) => p.name);
271
+ expect(order.indexOf('shadow')).toBeLessThan(order.indexOf('main'));
272
+ expect(order.indexOf('main')).toBeLessThan(order.indexOf('tonemap'));
273
+ });
274
+ });
275
+ }
276
+
277
+ {
278
+ // --- from bloom-topo.test.ts ---
279
+ // ── Helpers ──────────────────────────────────────────────────────
280
+
281
+ function mockCaps() {
282
+ return {
283
+ backendKind: 'webgpu' as const,
284
+ compute: true,
285
+ storageBuffer: true,
286
+ storageTexture: false,
287
+ timestampQuery: false,
288
+ timestampPeriodNanoseconds: null,
289
+ indirectDrawing: false,
290
+ textureCompressionBc: false,
291
+ textureCompressionEtc2: false,
292
+ textureCompressionAstc: false,
293
+ multiDrawIndirect: false,
294
+ pushConstants: false,
295
+ textureBindingArray: false,
296
+ samplerAliasing: true,
297
+ firstInstanceIndirect: false,
298
+ rgba16floatRenderable: false,
299
+ rg11b10ufloatRenderable: false,
300
+ float32Filterable: false,
301
+ maxColorAttachments: 8,
302
+ };
303
+ }
304
+
305
+ // ── Bloom declaration-order assertion ────────────────────────────
306
+
307
+ describe('bloom graph topology (feat-20260531-bloom)', () => {
308
+ it('compiles an 8-pass graph with bloom resources without error', () => {
309
+ const g = new RenderGraph();
310
+ g.addResource('depth', { kind: 'texture', lifetime: 'persistent' });
311
+ g.addResource('shadowDepth', { kind: 'texture', lifetime: 'persistent' });
312
+ g.addResource('fxaaIntermediate', { kind: 'texture', lifetime: 'transient' });
313
+ g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
314
+ g.addResource('hdrComposited', { kind: 'texture', lifetime: 'transient' });
315
+ g.addResource('bloomBright', { kind: 'texture', lifetime: 'transient' });
316
+ g.addResource('bloomBlurH', { kind: 'texture', lifetime: 'transient' });
317
+ g.addResource('bloomBlurV', { kind: 'texture', lifetime: 'transient' });
318
+ g.addPass('shadow', { reads: [], writes: ['shadowDepth'] });
319
+ g.addPass('main', { reads: ['shadowDepth'], writes: ['hdrColor', 'depth'] });
320
+ g.addPass('bloom-bright', { reads: ['hdrColor'], writes: ['bloomBright'] });
321
+ g.addPass('bloom-blur-h', { reads: ['bloomBright'], writes: ['bloomBlurH'] });
322
+ g.addPass('bloom-blur-v', { reads: ['bloomBlurH'], writes: ['bloomBlurV'] });
323
+ g.addPass('bloom-composite', {
324
+ reads: ['hdrColor', 'bloomBlurV'],
325
+ writes: ['hdrComposited'],
326
+ });
327
+ g.addPass('tonemap', { reads: ['hdrComposited'], writes: [] });
328
+ g.addPass('fxaa', { reads: [], writes: ['fxaaIntermediate'] });
329
+
330
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps() });
331
+ expect(r.ok).toBe(true);
332
+ });
333
+
334
+ it('places composite before tonemap (writes hdrComposited -> tonemap reads hdrComposited)', () => {
335
+ const g = new RenderGraph();
336
+ g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
337
+ g.addResource('hdrComposited', { kind: 'texture', lifetime: 'transient' });
338
+ g.addResource('bloomBlurV', { kind: 'texture', lifetime: 'transient' });
339
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
340
+ g.addPass('blur-v', { reads: [], writes: ['bloomBlurV'] });
341
+ g.addPass('bloom-composite', {
342
+ reads: ['hdrColor', 'bloomBlurV'],
343
+ writes: ['hdrComposited'],
344
+ });
345
+ g.addPass('tonemap', { reads: ['hdrComposited'], writes: [] });
346
+
347
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps() });
348
+ expect(r.ok).toBe(true);
349
+ if (!r.ok) return;
350
+ const order = r.value.passes.map((p) => p.name);
351
+ const compIdx = order.indexOf('bloom-composite');
352
+ const tonemapIdx = order.indexOf('tonemap');
353
+ expect(compIdx).toBeLessThan(tonemapIdx);
354
+ });
355
+
356
+ it('preserves bloom chain order: bright < blur-h < blur-v < composite', () => {
357
+ const g = new RenderGraph();
358
+ g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
359
+ g.addResource('hdrComposited', { kind: 'texture', lifetime: 'transient' });
360
+ g.addResource('bloomBright', { kind: 'texture', lifetime: 'transient' });
361
+ g.addResource('bloomBlurH', { kind: 'texture', lifetime: 'transient' });
362
+ g.addResource('bloomBlurV', { kind: 'texture', lifetime: 'transient' });
363
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
364
+ g.addPass('bloom-bright', { reads: ['hdrColor'], writes: ['bloomBright'] });
365
+ g.addPass('bloom-blur-h', { reads: ['bloomBright'], writes: ['bloomBlurH'] });
366
+ g.addPass('bloom-blur-v', { reads: ['bloomBlurH'], writes: ['bloomBlurV'] });
367
+ g.addPass('bloom-composite', {
368
+ reads: ['hdrColor', 'bloomBlurV'],
369
+ writes: ['hdrComposited'],
370
+ });
371
+
372
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps() });
373
+ expect(r.ok).toBe(true);
374
+ if (!r.ok) return;
375
+ const order = r.value.passes.map((p) => p.name);
376
+ const brightIdx = order.indexOf('bloom-bright');
377
+ const blurHIdx = order.indexOf('bloom-blur-h');
378
+ const blurVIdx = order.indexOf('bloom-blur-v');
379
+ const compIdx = order.indexOf('bloom-composite');
380
+ expect(brightIdx).toBeLessThan(blurHIdx);
381
+ expect(blurHIdx).toBeLessThan(blurVIdx);
382
+ expect(blurVIdx).toBeLessThan(compIdx);
383
+ });
384
+
385
+ it('uses hdrComposited to make the temporal version explicit', () => {
386
+ // bloom-bright reads the hdrColor version written by main.
387
+ // bloom-composite reads hdrColor + bloomBlurV, writes hdrComposited.
388
+ // tonemap reads hdrComposited.
389
+ // The chain: main -> bloom-bright -> blur-h -> blur-v -> composite -> tonemap.
390
+ // The hdrComposited resource name makes the post-composite version explicit while keeping the real
391
+ // GPU texture the same hdrColor slot (composite writes in-place, tonemap
392
+ // reads from it).
393
+ const g = new RenderGraph();
394
+ g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
395
+ g.addResource('hdrComposited', { kind: 'texture', lifetime: 'transient' });
396
+ g.addResource('bloomBright', { kind: 'texture', lifetime: 'transient' });
397
+ g.addResource('bloomBlurH', { kind: 'texture', lifetime: 'transient' });
398
+ g.addResource('bloomBlurV', { kind: 'texture', lifetime: 'transient' });
399
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
400
+ g.addPass('bloom-bright', { reads: ['hdrColor'], writes: ['bloomBright'] });
401
+ g.addPass('bloom-blur-h', { reads: ['bloomBright'], writes: ['bloomBlurH'] });
402
+ g.addPass('bloom-blur-v', { reads: ['bloomBlurH'], writes: ['bloomBlurV'] });
403
+ g.addPass('bloom-composite', {
404
+ reads: ['hdrColor', 'bloomBlurV'],
405
+ writes: ['hdrComposited'],
406
+ });
407
+ g.addPass('tonemap', { reads: ['hdrComposited'], writes: [] });
408
+
409
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps() });
410
+ expect(r.ok).toBe(true);
411
+ if (!r.ok) return;
412
+ const order = r.value.passes.map((p) => p.name);
413
+ const brightIdx = order.indexOf('bloom-bright');
414
+ const blurHIdx = order.indexOf('bloom-blur-h');
415
+ const blurVIdx = order.indexOf('bloom-blur-v');
416
+ const compIdx = order.indexOf('bloom-composite');
417
+ const tmapIdx = order.indexOf('tonemap');
418
+ expect(brightIdx).toBeLessThan(blurHIdx);
419
+ expect(blurHIdx).toBeLessThan(blurVIdx);
420
+ expect(blurVIdx).toBeLessThan(compIdx);
421
+ expect(compIdx).toBeLessThan(tmapIdx);
422
+ });
423
+ });
424
+ }
425
+
426
+ {
427
+ // --- from compile-fail-fast.test.ts ---
428
+ // ── Helpers ──────────────────────────────────────────────────────
429
+
430
+ function mockCaps(
431
+ overrides: { compute?: boolean; storageBuffer?: boolean },
432
+ backendKind: 'webgpu' | 'wgpu-native' | 'wgpu-webgl2' | 'null' = 'webgpu',
433
+ ) {
434
+ return {
435
+ backendKind,
436
+ compute: overrides.compute ?? true,
437
+ storageBuffer: overrides.storageBuffer ?? true,
438
+ storageTexture: false,
439
+ timestampQuery: false,
440
+ timestampPeriodNanoseconds: null,
441
+ indirectDrawing: false,
442
+ textureCompressionBc: false,
443
+ textureCompressionEtc2: false,
444
+ textureCompressionAstc: false,
445
+ multiDrawIndirect: false,
446
+ pushConstants: false,
447
+ textureBindingArray: false,
448
+ samplerAliasing: true,
449
+ firstInstanceIndirect: false,
450
+ rgba16floatRenderable: false,
451
+ rg11b10ufloatRenderable: false,
452
+ float32Filterable: false,
453
+ maxColorAttachments: 8,
454
+ };
455
+ }
456
+
457
+ // ── dangling-read ────────────────────────────────────────────────
458
+
459
+ describe('compile fail-fast: dangling-read', () => {
460
+ it('returns err with code dangling-read and detail { resourceKey, passName }', () => {
461
+ const g = new RenderGraph();
462
+ // orphanKey is a registered resource (so it is not unknown-resource), but
463
+ // no pass writes it -> dangling-read.
464
+ g.addResource('orphanKey', { kind: 'texture', lifetime: 'transient' });
465
+ g.addPass('passA', { reads: ['orphanKey'], writes: [] });
466
+
467
+ const r = g.compile({
468
+ backendKind: 'webgpu',
469
+ caps: mockCaps({}),
470
+ });
471
+
472
+ expect(r.ok).toBe(false);
473
+ if (!r.ok) {
474
+ const e = r.error as RenderGraphError;
475
+ expect(e.code).toBe('dangling-read');
476
+ const detail = e.detail as DanglingReadDetail;
477
+ expect(detail.resourceKey).toBe('orphanKey');
478
+ expect(detail.passName).toBe('passA');
479
+ }
480
+ });
481
+ });
482
+
483
+ // ── unknown-resource ─────────────────────────────────────────────
484
+
485
+ describe('compile fail-fast: unknown-resource', () => {
486
+ it('returns err with code unknown-resource when a pass reads an unregistered key', () => {
487
+ const g = new RenderGraph();
488
+ // 'ghost' is never registered via addResource -> unknown-resource.
489
+ g.addPass('passA', { reads: ['ghost'], writes: [] });
490
+
491
+ const r = g.compile({
492
+ backendKind: 'webgpu',
493
+ caps: mockCaps({}),
494
+ });
495
+
496
+ expect(r.ok).toBe(false);
497
+ if (!r.ok) {
498
+ const e = r.error as RenderGraphError;
499
+ expect(e.code).toBe('unknown-resource');
500
+ const detail = e.detail as DanglingReadDetail;
501
+ expect(detail.resourceKey).toBe('ghost');
502
+ expect(detail.passName).toBe('passA');
503
+ }
504
+ });
505
+
506
+ it('returns err with code unknown-resource when a pass writes an unregistered key', () => {
507
+ const g = new RenderGraph();
508
+ g.addPass('writer', { reads: [], writes: ['unregisteredOut'] });
509
+
510
+ const r = g.compile({
511
+ backendKind: 'webgpu',
512
+ caps: mockCaps({}),
513
+ });
514
+
515
+ expect(r.ok).toBe(false);
516
+ if (!r.ok) {
517
+ const e = r.error as RenderGraphError;
518
+ expect(e.code).toBe('unknown-resource');
519
+ const detail = e.detail as DanglingReadDetail;
520
+ expect(detail.resourceKey).toBe('unregisteredOut');
521
+ expect(detail.passName).toBe('writer');
522
+ }
523
+ });
524
+ });
525
+
526
+ // ── cap-missing (compute) ────────────────────────────────────────
527
+
528
+ describe('compile fail-fast: cap-missing', () => {
529
+ it('returns err with code cap-missing when compute pass on caps.compute=false backend', () => {
530
+ const g = new RenderGraph();
531
+ g.addPass('computePass', {
532
+ reads: [],
533
+ writes: ['outBuf'],
534
+ compute: true,
535
+ });
536
+
537
+ const r = g.compile({
538
+ backendKind: 'webgpu',
539
+ caps: mockCaps({ compute: false }),
540
+ });
541
+
542
+ expect(r.ok).toBe(false);
543
+ if (!r.ok) {
544
+ const e = r.error as RenderGraphError;
545
+ expect(e.code).toBe('cap-missing');
546
+ const detail = e.detail as CapMissingDetail;
547
+ expect(detail.cap).toBe('compute');
548
+ expect(detail.passName).toBe('computePass');
549
+ }
550
+ });
551
+
552
+ it('returns err with code cap-missing when storage buffer pass on caps.storageBuffer=false backend', () => {
553
+ const g = new RenderGraph();
554
+ g.addPass('storagePass', {
555
+ reads: [],
556
+ writes: ['buf'],
557
+ storageBuffer: true,
558
+ });
559
+
560
+ const r = g.compile({
561
+ backendKind: 'webgpu',
562
+ caps: mockCaps({ storageBuffer: false }),
563
+ });
564
+
565
+ expect(r.ok).toBe(false);
566
+ if (!r.ok) {
567
+ const e = r.error as RenderGraphError;
568
+ expect(e.code).toBe('cap-missing');
569
+ const detail = e.detail as CapMissingDetail;
570
+ expect(detail.cap).toBe('storageBuffer');
571
+ expect(detail.passName).toBe('storagePass');
572
+ }
573
+ });
574
+ });
575
+
576
+ describe('declaration-order temporal validation', () => {
577
+ it('rejects a read whose writer appears only in the future', () => {
578
+ const g = new RenderGraph();
579
+ g.addResource('X', { kind: 'texture', lifetime: 'transient' });
580
+ g.addResource('Y', { kind: 'texture', lifetime: 'transient' });
581
+ g.addResource('Z', { kind: 'texture', lifetime: 'transient' });
582
+ // A reads Y before any prior writer; later declarations cannot initialize it retroactively.
583
+ g.addPass('A', { reads: ['Y'], writes: ['X'] });
584
+ g.addPass('B', { reads: ['X'], writes: ['Y'] });
585
+ g.addPass('C', { reads: [], writes: ['Z'] });
586
+ const r = g.compile({
587
+ backendKind: 'webgpu',
588
+ caps: mockCaps({}),
589
+ });
590
+
591
+ expect(r.ok).toBe(false);
592
+ if (!r.ok) {
593
+ const e = r.error as RenderGraphError;
594
+ expect(e.code).toBe('dangling-read');
595
+ expect(e.detail).toMatchObject({ resourceKey: 'Y', passName: 'A' });
596
+ }
597
+ });
598
+ });
599
+
600
+ // ── duplicate-resource ───────────────────────────────────────────
601
+
602
+ describe('compile fail-fast: duplicate-resource', () => {
603
+ it('returns err with code duplicate-resource and detail { resourceKey }', () => {
604
+ const g = new RenderGraph();
605
+ g.addResource('dupKey', { kind: 'texture', lifetime: 'transient' });
606
+
607
+ const r = g.addResource('dupKey', {
608
+ kind: 'texture',
609
+ lifetime: 'persistent',
610
+ });
611
+
612
+ expect(r.ok).toBe(false);
613
+ if (!r.ok) {
614
+ const e = r.error as RenderGraphError;
615
+ expect(e.code).toBe('duplicate-resource');
616
+ const detail = e.detail as DuplicateResourceDetail;
617
+ expect(detail.resourceKey).toBe('dupKey');
618
+ }
619
+ });
620
+ });
621
+ }
622
+
623
+ {
624
+ // --- from errors.test.ts ---
625
+ // ── Closed union exhaustiveness ──────────────────────────────────
626
+
627
+ describe('RenderGraphErrorCode exhaustive switch', () => {
628
+ it('lists the complete closed error union', () => {
629
+ const allCodes: RenderGraphErrorCode[] = [
630
+ 'dangling-read',
631
+ 'cap-missing',
632
+ 'duplicate-resource',
633
+ 'alias-source-missing',
634
+ 'unknown-resource',
635
+ 'resource-alloc-failed',
636
+ 'invalid-format',
637
+ 'invalid-color-domain',
638
+ 'missing-color-domain',
639
+ 'color-domain-mismatch',
640
+ 'observation-absent',
641
+ 'observation-invalid-format',
642
+ 'observation-invalid-size',
643
+ 'observation-missing-copy-src',
644
+ 'observation-stale',
645
+ 'observation-retired',
646
+ 'duplicate-pass-name',
647
+ 'duplicate-resource-label',
648
+ 'builder-sealed',
649
+ 'foreign-resource-handle',
650
+ 'resource-not-declared-by-pass',
651
+ 'uninitialized-read',
652
+ 'access-conflict',
653
+ 'capability-missing',
654
+ 'resource-descriptor-invalid',
655
+ 'import-usage-mismatch',
656
+ 'resource-allocation-failed',
657
+ 'resource-resolution-failed',
658
+ 'pass-encode-failed',
659
+ 'compiled-graph-retired',
660
+ 'resource-retire-failed',
661
+ ];
662
+ expect(allCodes).toHaveLength(31);
663
+ });
664
+
665
+ it('allows exhaustive switch without default covering every member', () => {
666
+ function handle(code: RenderGraphErrorCode): string {
667
+ switch (code) {
668
+ case 'dangling-read':
669
+ return 'dangling';
670
+ case 'cap-missing':
671
+ return 'cap';
672
+ case 'duplicate-resource':
673
+ return 'dup';
674
+ case 'alias-source-missing':
675
+ return 'alias-source-missing';
676
+ case 'unknown-resource':
677
+ return 'unknown';
678
+ case 'resource-alloc-failed':
679
+ return 'alloc';
680
+ case 'invalid-format':
681
+ return 'format';
682
+ case 'invalid-color-domain':
683
+ return 'invalid-domain';
684
+ case 'missing-color-domain':
685
+ return 'missing-domain';
686
+ case 'color-domain-mismatch':
687
+ return 'domain-mismatch';
688
+ case 'observation-absent':
689
+ return 'observation-absent';
690
+ case 'observation-invalid-format':
691
+ return 'observation-invalid-format';
692
+ case 'observation-invalid-size':
693
+ return 'observation-invalid-size';
694
+ case 'observation-missing-copy-src':
695
+ return 'observation-missing-copy-src';
696
+ case 'observation-stale':
697
+ return 'observation-stale';
698
+ case 'observation-retired':
699
+ return 'observation-retired';
700
+ case 'duplicate-pass-name':
701
+ case 'duplicate-resource-label':
702
+ case 'builder-sealed':
703
+ case 'foreign-resource-handle':
704
+ case 'resource-not-declared-by-pass':
705
+ case 'uninitialized-read':
706
+ case 'access-conflict':
707
+ case 'capability-missing':
708
+ case 'resource-descriptor-invalid':
709
+ case 'import-usage-mismatch':
710
+ case 'resource-allocation-failed':
711
+ case 'resource-resolution-failed':
712
+ case 'pass-encode-failed':
713
+ case 'compiled-graph-retired':
714
+ case 'resource-retire-failed':
715
+ return code;
716
+ }
717
+ }
718
+
719
+ expect(handle('dangling-read')).toBe('dangling');
720
+ expect(handle('cap-missing')).toBe('cap');
721
+ expect(handle('duplicate-resource')).toBe('dup');
722
+ expect(handle('alias-source-missing')).toBe('alias-source-missing');
723
+ expect(handle('unknown-resource')).toBe('unknown');
724
+ expect(handle('resource-alloc-failed')).toBe('alloc');
725
+ expect(handle('invalid-format')).toBe('format');
726
+ expect(handle('invalid-color-domain')).toBe('invalid-domain');
727
+ expect(handle('missing-color-domain')).toBe('missing-domain');
728
+ expect(handle('color-domain-mismatch')).toBe('domain-mismatch');
729
+ });
730
+ });
731
+
732
+ // ── M1 new error detail variants ──────────────────────────────────
733
+
734
+ describe('RenderGraphError new detail variants (resource-alloc-failed + invalid-format)', () => {
735
+ it('resource-alloc-failed error has code, expected, hint, and detail with { resourceKey, passName?, rhiCode? }', () => {
736
+ const e = new RenderGraphError({
737
+ code: 'resource-alloc-failed',
738
+ expected: 'device.createTexture must succeed',
739
+ hint: 'check the device state and recreate if lost',
740
+ detail: { resourceKey: 'hdrColor', passName: 'main', rhiCode: 'limit-exceeded' },
741
+ });
742
+
743
+ expect(e.code).toBe('resource-alloc-failed');
744
+ expect(e.expected).toBe('device.createTexture must succeed');
745
+ expect(e.hint).toBe('check the device state and recreate if lost');
746
+ const d = e.detail as { resourceKey: string; passName?: string; rhiCode?: string };
747
+ expect(d.resourceKey).toBe('hdrColor');
748
+ expect(d.passName).toBe('main');
749
+ expect(d.rhiCode).toBe('limit-exceeded');
750
+ });
751
+
752
+ it('invalid-format error has code, expected, hint, and detail with { resourceKey, format, expected: string[] }', () => {
753
+ const e = new RenderGraphError({
754
+ code: 'invalid-format',
755
+ expected: "format must be a valid GPU texture format, got 'rgb9e5ufloat'",
756
+ hint: 'use a standard format like rgba16float or bgra8unorm',
757
+ detail: {
758
+ resourceKey: 'myTarget',
759
+ format: 'rgb9e5ufloat',
760
+ expected: ['rgba16float', 'rgba8unorm', 'bgra8unorm'],
761
+ },
762
+ });
763
+
764
+ expect(e.code).toBe('invalid-format');
765
+ expect(e.expected).toContain('rgb9e5ufloat');
766
+ const d = e.detail as { resourceKey: string; format: string; expected: readonly string[] };
767
+ expect(d.resourceKey).toBe('myTarget');
768
+ expect(d.format).toBe('rgb9e5ufloat');
769
+ expect(d.expected).toContain('rgba16float');
770
+ });
771
+ });
772
+
773
+ // ── RenderGraphError four fields ─────────────────────────────────
774
+
775
+ describe('RenderGraphError structured error', () => {
776
+ it('has all four fields: .code, .expected, .hint, .detail', () => {
777
+ const e = new RenderGraphError({
778
+ code: 'dangling-read',
779
+ expected: 'key registered',
780
+ hint: 'add a write',
781
+ detail: { resourceKey: 'K', passName: 'P' },
782
+ });
783
+
784
+ expect(e.code).toBe('dangling-read');
785
+ expect(e.expected).toBe('key registered');
786
+ expect(e.hint).toBe('add a write');
787
+ expect(e.detail).toEqual({ resourceKey: 'K', passName: 'P' });
788
+ expect(e).toBeInstanceOf(Error);
789
+ expect(e.name).toBe('RenderGraphError');
790
+ expect(e.message).toContain('[RenderGraphError dangling-read]');
791
+ });
792
+
793
+ it('allows .detail to be undefined', () => {
794
+ const e = new RenderGraphError({
795
+ code: 'dangling-read',
796
+ expected: 'x',
797
+ hint: 'y',
798
+ });
799
+ expect(e.detail).toBeUndefined();
800
+ });
801
+ });
802
+
803
+ // ── Detail narrowing per code ────────────────────────────────────
804
+
805
+ describe('RenderGraphErrorDetail narrowing', () => {
806
+ it('dangling-read detail has resourceKey and passName', () => {
807
+ const e = new RenderGraphError({
808
+ code: 'dangling-read',
809
+ expected: '',
810
+ hint: '',
811
+ detail: { resourceKey: 'orphan', passName: 'passA' },
812
+ });
813
+ const d = e.detail as { resourceKey: string; passName: string };
814
+ expect(d.resourceKey).toBe('orphan');
815
+ expect(d.passName).toBe('passA');
816
+ });
817
+
818
+ it('cap-missing detail has cap and passName', () => {
819
+ const e = new RenderGraphError({
820
+ code: 'cap-missing',
821
+ expected: '',
822
+ hint: '',
823
+ detail: { cap: 'compute', passName: 'computePass' },
824
+ });
825
+ const d = e.detail as { cap: string; passName: string };
826
+ expect(d.cap).toBe('compute');
827
+ expect(d.passName).toBe('computePass');
828
+ });
829
+
830
+ it('duplicate-resource detail has resourceKey', () => {
831
+ const e = new RenderGraphError({
832
+ code: 'duplicate-resource',
833
+ expected: '',
834
+ hint: '',
835
+ detail: { resourceKey: 'dupKey' },
836
+ });
837
+ const d = e.detail as { resourceKey: string };
838
+ expect(d.resourceKey).toBe('dupKey');
839
+ });
840
+
841
+ it('alias-source-missing detail has aliasKey and sourceKey', () => {
842
+ const e = new RenderGraphError({
843
+ code: 'alias-source-missing',
844
+ expected: '',
845
+ hint: '',
846
+ detail: { aliasKey: 'alias', sourceKey: 'source' },
847
+ });
848
+ const d = e.detail as { aliasKey: string; sourceKey: string };
849
+ expect(d.aliasKey).toBe('alias');
850
+ expect(d.sourceKey).toBe('source');
851
+ });
852
+
853
+ it('unknown-resource detail has resourceKey and passName', () => {
854
+ const e = new RenderGraphError({
855
+ code: 'unknown-resource',
856
+ expected: '',
857
+ hint: '',
858
+ detail: { resourceKey: 'orphan', passName: 'passA' },
859
+ });
860
+ const d = e.detail as { resourceKey: string; passName: string };
861
+ expect(d.resourceKey).toBe('orphan');
862
+ expect(d.passName).toBe('passA');
863
+ });
864
+ });
865
+
866
+ // ── Result ok/err factories ──────────────────────────────────────
867
+
868
+ describe('Result<T, E> ok / err factories', () => {
869
+ it('ok() produces ResultOk with .ok=true and .value', () => {
870
+ const r = ok(42);
871
+ expect(r.ok).toBe(true);
872
+ if (r.ok) {
873
+ expect(r.value).toBe(42);
874
+ expect(r.unwrap()).toBe(42);
875
+ expect(r.unwrapOr(0)).toBe(42);
876
+ }
877
+ });
878
+
879
+ it('err() produces ResultErr with .ok=false and .error', () => {
880
+ const e = new RenderGraphError({
881
+ code: 'dangling-read',
882
+ expected: 'x',
883
+ hint: 'y',
884
+ });
885
+ const r = err(e);
886
+ expect(r.ok).toBe(false);
887
+ if (!r.ok) {
888
+ expect(r.error).toBe(e);
889
+ expect(r.unwrapOr(42)).toBe(42);
890
+ expect(() => r.unwrap()).toThrow(e);
891
+ }
892
+ });
893
+
894
+ it('Result type narrows correctly via .ok discriminant', () => {
895
+ const r1 = ok('hello');
896
+ const r2 = err(
897
+ new RenderGraphError({
898
+ code: 'dangling-read',
899
+ expected: '',
900
+ hint: '',
901
+ }),
902
+ );
903
+
904
+ if (r1.ok) {
905
+ expect(typeof r1.value).toBe('string');
906
+ }
907
+ if (!r2.ok) {
908
+ expect(r2.error).toBeInstanceOf(RenderGraphError);
909
+ }
910
+ });
911
+ });
912
+ }
913
+
914
+ {
915
+ // --- from list-passes-resources.test.ts ---
916
+ describe('listPasses / listResources query interface (D-5)', () => {
917
+ it('listPasses returns passes in declaration order', () => {
918
+ const g = new RenderGraph();
919
+ g.addPass('shadow', { reads: [], writes: ['depth'] });
920
+ g.addPass('main', { reads: ['depth'], writes: ['color'] });
921
+ g.addPass('tonemap', { reads: ['color'], writes: ['swapchain'] });
922
+
923
+ const passes = g.listPasses();
924
+ expect(passes).toHaveLength(3);
925
+ const [p0, p1, p2] = passes;
926
+ expect(p0?.name).toBe('shadow');
927
+ expect(p0?.reads).toEqual([]);
928
+ expect(p0?.writes).toEqual(['depth']);
929
+ expect(p1?.name).toBe('main');
930
+ expect(p1?.reads).toEqual(['depth']);
931
+ expect(p1?.writes).toEqual(['color']);
932
+ expect(p2?.name).toBe('tonemap');
933
+ expect(p2?.reads).toEqual(['color']);
934
+ expect(p2?.writes).toEqual(['swapchain']);
935
+ });
936
+
937
+ it('listResources returns registered resources with key/kind/lifetime', () => {
938
+ const g = new RenderGraph();
939
+ g.addResource('depth', { kind: 'texture', lifetime: 'persistent' });
940
+ g.addResource('color', { kind: 'texture', lifetime: 'transient' });
941
+ g.addResource('lightBuf', {
942
+ kind: 'buffer',
943
+ lifetime: 'persistent',
944
+ bufferRole: 'uniform',
945
+ });
946
+
947
+ const resources = g.listResources();
948
+ expect(resources).toHaveLength(3);
949
+
950
+ const depth = resources.find((r) => r.key === 'depth');
951
+ expect(depth).toBeDefined();
952
+ expect(depth?.kind).toBe('texture');
953
+ expect(depth?.lifetime).toBe('persistent');
954
+
955
+ const color = resources.find((r) => r.key === 'color');
956
+ expect(color).toBeDefined();
957
+ expect(color?.kind).toBe('texture');
958
+ expect(color?.lifetime).toBe('transient');
959
+
960
+ const buf = resources.find((r) => r.key === 'lightBuf');
961
+ expect(buf).toBeDefined();
962
+ expect(buf?.kind).toBe('buffer');
963
+ expect(buf?.lifetime).toBe('persistent');
964
+ });
965
+
966
+ it('listPasses returns readonly (cannot push)', () => {
967
+ const g = new RenderGraph();
968
+ g.addPass('A', { reads: [], writes: ['X'] });
969
+ const passes = g.listPasses();
970
+ expect(Array.isArray(passes)).toBe(true);
971
+ });
972
+
973
+ it('dangling writes appear in listPasses silently (D-5: ignore)', () => {
974
+ const g = new RenderGraph();
975
+ g.addPass('producer', { reads: [], writes: ['orphanOutput'] });
976
+ const passes = g.listPasses();
977
+ expect(passes).toHaveLength(1);
978
+ expect(passes[0]?.writes).toContain('orphanOutput');
979
+ });
980
+ });
981
+
982
+ // ── w15: per-frame 4-pass graph enumeration ──────────────────────
983
+ //
984
+ // feat-20260529-rendergraph-pass-abstraction M4 / w15. Asserts listPasses /
985
+ // listResources over the EXACT shape the runtime declares in
986
+ // render-system-record.ts so the AI-user discoverability promise (D-5) is
987
+ // pinned to the real per-frame graph: 4 passes (shadow/main/tonemap/fxaa) in
988
+ // declaration order with the migrated reads/writes, and 4 transient/
989
+ // persistent texture resources. IBL convolution + shadow probe (OOS-3/OOS-4)
990
+ // are NOT graph passes -> not enumerated.
991
+
992
+ function buildPerFrameGraph() {
993
+ const g = new RenderGraph();
994
+ g.addResource('depth', { kind: 'texture', lifetime: 'persistent' });
995
+ g.addResource('shadowDepth', { kind: 'texture', lifetime: 'persistent' });
996
+ g.addResource('fxaaIntermediate', { kind: 'texture', lifetime: 'transient' });
997
+ g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
998
+ g.addPass('shadow', { reads: [], writes: ['shadowDepth'] });
999
+ g.addPass('main', { reads: ['shadowDepth'], writes: ['hdrColor', 'depth'] });
1000
+ g.addPass('tonemap', { reads: ['hdrColor'], writes: [] });
1001
+ g.addPass('fxaa', { reads: [], writes: ['fxaaIntermediate'] });
1002
+ return g;
1003
+ }
1004
+
1005
+ describe('w15: per-frame 4-pass graph enumeration', () => {
1006
+ it('listPasses returns the 4 per-frame passes in declaration order', () => {
1007
+ const passes = buildPerFrameGraph().listPasses();
1008
+ expect(passes.map((p) => p.name)).toEqual(['shadow', 'main', 'tonemap', 'fxaa']);
1009
+ });
1010
+
1011
+ it('each pass reads/writes match the runtime declaration', () => {
1012
+ const passes = buildPerFrameGraph().listPasses();
1013
+ const byName = new Map(passes.map((p) => [p.name, p]));
1014
+
1015
+ expect(byName.get('shadow')?.reads).toEqual([]);
1016
+ expect(byName.get('shadow')?.writes).toEqual(['shadowDepth']);
1017
+
1018
+ expect(byName.get('main')?.reads).toEqual(['shadowDepth']);
1019
+ expect(byName.get('main')?.writes).toEqual(['hdrColor', 'depth']);
1020
+
1021
+ expect(byName.get('tonemap')?.reads).toEqual(['hdrColor']);
1022
+ expect(byName.get('tonemap')?.writes).toEqual([]);
1023
+
1024
+ // FXAA's intermediate is pass-internal (copy target), declared under
1025
+ // writes so compile() raises no dangling-read (D-5 dangling-write OK).
1026
+ expect(byName.get('fxaa')?.reads).toEqual([]);
1027
+ expect(byName.get('fxaa')?.writes).toEqual(['fxaaIntermediate']);
1028
+ });
1029
+
1030
+ it('listResources enumerates the 4 texture resources with key/kind/lifetime', () => {
1031
+ const resources = buildPerFrameGraph().listResources();
1032
+ expect(resources).toHaveLength(4);
1033
+ const byKey = new Map(resources.map((r) => [r.key, r]));
1034
+
1035
+ for (const key of ['depth', 'shadowDepth', 'fxaaIntermediate', 'hdrColor']) {
1036
+ expect(byKey.get(key)?.kind).toBe('texture');
1037
+ }
1038
+ // Lifetime split (D-4): depth + shadowDepth persistent (size/mapSize
1039
+ // keyed, survive across frames); fxaaIntermediate + hdrColor transient
1040
+ // (conditional on camera.antialias / camera.tonemap, resize-invalidated).
1041
+ expect(byKey.get('depth')?.lifetime).toBe('persistent');
1042
+ expect(byKey.get('shadowDepth')?.lifetime).toBe('persistent');
1043
+ expect(byKey.get('fxaaIntermediate')?.lifetime).toBe('transient');
1044
+ expect(byKey.get('hdrColor')?.lifetime).toBe('transient');
1045
+ });
1046
+
1047
+ it('listResources includes the dangling-write fxaaIntermediate (D-5: not pruned)', () => {
1048
+ // fxaaIntermediate is written by 'fxaa' but read by no pass; D-5 keeps it
1049
+ // enumerated (dangling write is a legitimate "external consumer" output).
1050
+ const resources = buildPerFrameGraph().listResources();
1051
+ expect(resources.some((r) => r.key === 'fxaaIntermediate')).toBe(true);
1052
+ });
1053
+ });
1054
+ }
1055
+
1056
+ {
1057
+ // --- from resource-allocation.test.ts ---
1058
+ // ── AC-01(a): compile with device calls createTexture ──────────────
1059
+
1060
+ describe('addColorTarget compile allocation', () => {
1061
+ it.todo(
1062
+ 'addColorTarget(name, desc) returns a handle that resolve() gives a TextureView after compile',
1063
+ );
1064
+
1065
+ it.todo(
1066
+ 'compile({device}) calls device.createTexture at least once when the graph has color targets',
1067
+ );
1068
+
1069
+ it.todo(
1070
+ 'pass execute closure receives a context where resolve(name) returns a non-null TextureView',
1071
+ );
1072
+ });
1073
+
1074
+ // ── AC-02(a): transient resource pooling by descriptor ─────────────
1075
+
1076
+ describe('transient resource pooling', () => {
1077
+ it.todo(
1078
+ 'transient resources with identical descriptor share the same physical texture (pool hit)',
1079
+ );
1080
+
1081
+ it.todo('transient resource with changed size triggers drift rebuild (new physical texture)');
1082
+
1083
+ it.todo('transient resource with changed format triggers drift rebuild');
1084
+
1085
+ it.todo(
1086
+ 'sampleCount in pool key isolates MSAA multi-sampled targets from single-sampled targets',
1087
+ );
1088
+ });
1089
+
1090
+ // ── AC-02(b): persistent resource behaviour ───────────────────────
1091
+
1092
+ describe('persistent resource lifecycle', () => {
1093
+ it('reuses persistent targets until size drift, then replaces only the drifted target once', async () => {
1094
+ const created: Array<{
1095
+ readonly texture: object;
1096
+ readonly width: number;
1097
+ readonly height: number;
1098
+ }> = [];
1099
+ const destroyed: object[] = [];
1100
+ const device = {
1101
+ createTexture: (descriptor: {
1102
+ readonly size: { readonly width: number; readonly height: number };
1103
+ }) => {
1104
+ const texture = { id: `texture-${created.length + 1}` };
1105
+ created.push({ texture, width: descriptor.size.width, height: descriptor.size.height });
1106
+ return { ok: true as const, value: texture };
1107
+ },
1108
+ createTextureView: (texture: object) => ({
1109
+ ok: true as const,
1110
+ value: { id: `view-${created.length}`, texture },
1111
+ }),
1112
+ destroyTexture: (texture: object) => {
1113
+ destroyed.push(texture);
1114
+ return { ok: true as const, value: undefined };
1115
+ },
1116
+ queue: { onSubmittedWorkDone: () => Promise.resolve(undefined) },
1117
+ };
1118
+ const caps = { backendKind: 'webgpu', compute: true, storageBuffer: true } as never;
1119
+ const graph = new RenderGraph();
1120
+ graph.addColorTarget('persistent', {
1121
+ format: 'rgba16float',
1122
+ size: 'swapchain',
1123
+ lifetime: 'persistent',
1124
+ usage: 0,
1125
+ });
1126
+ graph.addColorTarget('sibling', {
1127
+ format: 'rgba16float',
1128
+ size: { w: 16, h: 16 },
1129
+ lifetime: 'persistent',
1130
+ usage: 0,
1131
+ });
1132
+ graph.addPass('main', { reads: [], writes: ['persistent', 'sibling'] });
1133
+
1134
+ graph.setSwapChainSize(64, 32);
1135
+ expect(graph.compile({ backendKind: 'webgpu', caps, device: device as never }).ok).toBe(true);
1136
+ const persistentTexture = graph.getColorTargetTexture('persistent');
1137
+ const persistentView = graph.getColorTargetView('persistent');
1138
+ const siblingTexture = graph.getColorTargetTexture('sibling');
1139
+ expect(
1140
+ graph.listResources().find((resource) => resource.key === 'persistent')?.lifetime,
1141
+ ).toBe('persistent');
1142
+ expect(created.map(({ width, height }) => [width, height])).toEqual([
1143
+ [64, 32],
1144
+ [16, 16],
1145
+ ]);
1146
+
1147
+ graph.setSwapChainSize(64, 32);
1148
+ expect(graph.compile({ backendKind: 'webgpu', caps, device: device as never }).ok).toBe(true);
1149
+ expect(graph.getColorTargetTexture('persistent')).toBe(persistentTexture);
1150
+ expect(graph.getColorTargetView('persistent')).toBe(persistentView);
1151
+ expect(graph.getColorTargetTexture('sibling')).toBe(siblingTexture);
1152
+ expect(created).toHaveLength(2);
1153
+
1154
+ graph.setSwapChainSize(128, 32);
1155
+ expect(graph.compile({ backendKind: 'webgpu', caps, device: device as never }).ok).toBe(true);
1156
+ const replacementTexture = graph.getColorTargetTexture('persistent');
1157
+ const replacementView = graph.getColorTargetView('persistent');
1158
+ expect(replacementTexture).not.toBe(persistentTexture);
1159
+ expect(replacementView).not.toBe(persistentView);
1160
+ expect(graph.getColorTargetTexture('sibling')).toBe(siblingTexture);
1161
+ expect(graph.getColorTargetDescriptor('persistent')?.size).toEqual({
1162
+ width: 128,
1163
+ height: 32,
1164
+ });
1165
+ expect(created.map(({ width, height }) => [width, height])).toEqual([
1166
+ [64, 32],
1167
+ [16, 16],
1168
+ [128, 32],
1169
+ ]);
1170
+ expect(destroyed).toEqual([]);
1171
+
1172
+ await graph.reclaimRetiredTransients();
1173
+ expect(destroyed).toEqual([persistentTexture]);
1174
+
1175
+ graph.setSwapChainSize(128, 32);
1176
+ expect(graph.compile({ backendKind: 'webgpu', caps, device: device as never }).ok).toBe(true);
1177
+ expect(graph.getColorTargetTexture('persistent')).toBe(replacementTexture);
1178
+ expect(graph.getColorTargetView('persistent')).toBe(replacementView);
1179
+ expect(created).toHaveLength(3);
1180
+
1181
+ graph.drain();
1182
+ graph.drain();
1183
+ expect(destroyed).toEqual([persistentTexture, replacementTexture, siblingTexture]);
1184
+ });
1185
+ });
1186
+
1187
+ // ── AC-02(c): alias folding (D-2 / KB-1) ─────────────────────────
1188
+
1189
+ describe('alias folding (hdrComposited -> physical texture)', () => {
1190
+ it.todo('alias-folded logical resources resolve to the same physical texture');
1191
+ });
1192
+
1193
+ // ── compile fail-fast with device errors ───────────────────────────
1194
+
1195
+ describe('compile fail-fast with device errors (w6)', () => {
1196
+ it('refuses allocation atomically and preserves the active graph for clean retry', () => {
1197
+ const created: object[] = [];
1198
+ const destroyed: object[] = [];
1199
+ let createCalls = 0;
1200
+ let failOnCreateCall: number | undefined;
1201
+ const device = {
1202
+ createTexture: () => {
1203
+ createCalls += 1;
1204
+ if (createCalls === failOnCreateCall) {
1205
+ return { ok: false as const, error: { code: 'oom' } };
1206
+ }
1207
+ const texture = { id: `texture-${createCalls}` };
1208
+ created.push(texture);
1209
+ return { ok: true as const, value: texture };
1210
+ },
1211
+ createTextureView: (texture: object) => ({
1212
+ ok: true as const,
1213
+ value: { texture },
1214
+ }),
1215
+ destroyTexture: (texture: object) => {
1216
+ destroyed.push(texture);
1217
+ return { ok: true as const, value: undefined };
1218
+ },
1219
+ queue: { onSubmittedWorkDone: () => Promise.resolve(undefined) },
1220
+ };
1221
+ const caps = {
1222
+ backendKind: 'webgpu',
1223
+ compute: true,
1224
+ storageBuffer: true,
1225
+ } as never;
1226
+
1227
+ const graph = new RenderGraph();
1228
+ graph.addColorTarget('healthy', {
1229
+ format: 'rgba16float',
1230
+ size: { w: 32, h: 32 },
1231
+ usage: 0,
1232
+ });
1233
+ graph.addPass('healthy-pass', { reads: [], writes: ['healthy'] });
1234
+
1235
+ const initial = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
1236
+ expect(initial.ok).toBe(true);
1237
+ const healthyTexture = graph.getColorTargetTexture('healthy');
1238
+ const healthyView = graph.getColorTargetView('healthy');
1239
+ expect(healthyTexture).toBeDefined();
1240
+ expect(healthyView).toBeDefined();
1241
+
1242
+ graph.addColorTarget('new-target', {
1243
+ format: 'rgba16float',
1244
+ size: { w: 32, h: 32 },
1245
+ usage: 0,
1246
+ });
1247
+ graph.addColorTarget('faulty-target', {
1248
+ format: 'rgba16float',
1249
+ size: { w: 32, h: 32 },
1250
+ usage: 0,
1251
+ });
1252
+ graph.addPass('new-pass', { reads: [], writes: ['new-target'] });
1253
+ graph.addPass('faulty-pass', { reads: [], writes: ['faulty-target'] });
1254
+ failOnCreateCall = createCalls + 2;
1255
+
1256
+ const refused = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
1257
+ expect(refused.ok).toBe(false);
1258
+ if (refused.ok) return;
1259
+ expect(refused.error.code).toBe('resource-alloc-failed');
1260
+ expect(refused.error.detail).toEqual({
1261
+ resourceKey: 'faulty-target',
1262
+ rhiCode: 'oom',
1263
+ });
1264
+ expect(destroyed).toEqual([created[1]]);
1265
+ expect(graph.getColorTargetTexture('healthy')).toBe(healthyTexture);
1266
+ expect(graph.getColorTargetView('healthy')).toBe(healthyView);
1267
+ expect(graph.getColorTargetTexture('new-target')).toBeUndefined();
1268
+ expect(graph.getColorTargetTexture('faulty-target')).toBeUndefined();
1269
+
1270
+ failOnCreateCall = undefined;
1271
+ const recovered = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
1272
+ expect(recovered.ok).toBe(true);
1273
+ expect(graph.getColorTargetTexture('new-target')).not.toBe(created[1]);
1274
+ expect(graph.getColorTargetTexture('faulty-target')).toBeDefined();
1275
+
1276
+ graph.drain();
1277
+ graph.drain();
1278
+ expect(new Set(destroyed)).toEqual(new Set(created));
1279
+ expect(destroyed).toHaveLength(created.length);
1280
+ });
1281
+
1282
+ it('returns invalid-format before allocation and preserves the active graph', () => {
1283
+ let createTextureCalls = 0;
1284
+ let createTextureViewCalls = 0;
1285
+ const device = {
1286
+ createTexture: () => {
1287
+ createTextureCalls += 1;
1288
+ return { ok: true as const, value: { id: `texture-${createTextureCalls}` } };
1289
+ },
1290
+ createTextureView: () => {
1291
+ createTextureViewCalls += 1;
1292
+ return { ok: true as const, value: { id: `view-${createTextureViewCalls}` } };
1293
+ },
1294
+ destroyTexture: () => ({ ok: true as const, value: undefined }),
1295
+ queue: { onSubmittedWorkDone: () => Promise.resolve(undefined) },
1296
+ };
1297
+ const caps = {
1298
+ backendKind: 'webgpu',
1299
+ compute: true,
1300
+ storageBuffer: true,
1301
+ } as never;
1302
+
1303
+ const graph = new RenderGraph();
1304
+ graph.addColorTarget('healthy', {
1305
+ format: 'rgba8unorm',
1306
+ size: { w: 1, h: 1 },
1307
+ usage: 0,
1308
+ });
1309
+ graph.addPass('healthy-pass', { reads: [], writes: ['healthy'] });
1310
+ const initial = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
1311
+ expect(initial.ok).toBe(true);
1312
+ const healthyTexture = graph.getColorTargetTexture('healthy');
1313
+ const healthyView = graph.getColorTargetView('healthy');
1314
+ expect(createTextureCalls).toBe(1);
1315
+ expect(createTextureViewCalls).toBe(1);
1316
+
1317
+ graph.addColorTarget('candidate', {
1318
+ // Runtime malformed input is intentionally injected past the closed
1319
+ // declaration type so the validation error remains covered.
1320
+ format: 'not-a-gpu-texture-format' as never,
1321
+ size: { w: 1, h: 1 },
1322
+ usage: 0,
1323
+ });
1324
+ graph.addPass('candidate-pass', { reads: [], writes: ['candidate'] });
1325
+
1326
+ const refused = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
1327
+ expect(refused.ok).toBe(false);
1328
+ if (refused.ok) return;
1329
+ expect(refused.error.code).toBe('invalid-format');
1330
+ expect(refused.error.detail).toMatchObject({
1331
+ resourceKey: 'candidate',
1332
+ format: 'not-a-gpu-texture-format',
1333
+ });
1334
+ const detail = refused.error.detail as {
1335
+ readonly resourceKey: string;
1336
+ readonly format: string;
1337
+ readonly expected: readonly string[];
1338
+ };
1339
+ expect(detail.expected).toContain('rgba8unorm');
1340
+ expect(createTextureCalls).toBe(1);
1341
+ expect(createTextureViewCalls).toBe(1);
1342
+ expect(graph.getColorTargetTexture('healthy')).toBe(healthyTexture);
1343
+ expect(graph.getColorTargetView('healthy')).toBe(healthyView);
1344
+ expect(graph.getColorTargetTexture('candidate')).toBeUndefined();
1345
+
1346
+ const duplicateCandidate = graph.addColorTarget('candidate', {
1347
+ format: 'rgba8unorm',
1348
+ size: { w: 1, h: 1 },
1349
+ usage: 0,
1350
+ });
1351
+ expect(duplicateCandidate.ok).toBe(false);
1352
+ if (duplicateCandidate.ok) return;
1353
+ expect(duplicateCandidate.error.code).toBe('duplicate-resource');
1354
+ expect(duplicateCandidate.error.detail).toEqual({ resourceKey: 'candidate' });
1355
+ expect(createTextureCalls).toBe(1);
1356
+ expect(createTextureViewCalls).toBe(1);
1357
+ expect(graph.getColorTargetTexture('healthy')).toBe(healthyTexture);
1358
+ expect(graph.getColorTargetView('healthy')).toBe(healthyView);
1359
+ expect(graph.getColorTargetTexture('candidate')).toBeUndefined();
1360
+ });
1361
+ });
1362
+ }
1363
+
1364
+ {
1365
+ // --- feat-20260612 M4 / w13: RenderGraph.drain unit test ---
1366
+ //
1367
+ // Coverage matrix (plan-strategy D-7 + OOS-7):
1368
+ // 1. drain() walks transientPool and calls device.destroyTexture for each
1369
+ // pooled texture handle.
1370
+ // 2. drain() walks persistentTextures and calls device.destroyTexture for
1371
+ // each persistent texture handle.
1372
+ // 3. After drain(), both pools are empty (size === 0).
1373
+ // 4. drain() is idempotent: a second call after the pools were cleared
1374
+ // is a no-op (zero further destroyTexture invocations).
1375
+ //
1376
+ // Mock shape: a minimal RhiDevice stub with createTexture / createTextureView
1377
+ // / destroyTexture (the only surface the allocate + drain paths touch).
1378
+
1379
+ function drainMockCaps() {
1380
+ return {
1381
+ backendKind: 'webgpu' as const,
1382
+ compute: true,
1383
+ storageBuffer: true,
1384
+ storageTexture: false,
1385
+ timestampQuery: false,
1386
+ timestampPeriodNanoseconds: null,
1387
+ indirectDrawing: false,
1388
+ textureCompressionBc: false,
1389
+ textureCompressionEtc2: false,
1390
+ textureCompressionAstc: false,
1391
+ multiDrawIndirect: false,
1392
+ pushConstants: false,
1393
+ textureBindingArray: false,
1394
+ samplerAliasing: true,
1395
+ firstInstanceIndirect: false,
1396
+ rgba16floatRenderable: false,
1397
+ rg11b10ufloatRenderable: false,
1398
+ float32Filterable: false,
1399
+ maxColorAttachments: 8,
1400
+ };
1401
+ }
1402
+
1403
+ interface DrainProbe {
1404
+ created: number;
1405
+ destroyed: number;
1406
+ destroyedHandles: object[];
1407
+ }
1408
+
1409
+ function makeDrainDevice(probe: DrainProbe): {
1410
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1411
+ [k: string]: any;
1412
+ } {
1413
+ return {
1414
+ createTexture: (_desc: unknown) => {
1415
+ probe.created += 1;
1416
+ const handle = { __mock: `tex-${probe.created}` };
1417
+ return { ok: true as const, value: handle };
1418
+ },
1419
+ createTextureView: (_tex: unknown, _desc: unknown) => {
1420
+ return { ok: true as const, value: { __mock: 'view' } };
1421
+ },
1422
+ destroyTexture: (tex: object) => {
1423
+ probe.destroyed += 1;
1424
+ probe.destroyedHandles.push(tex);
1425
+ return { ok: true as const, value: undefined };
1426
+ },
1427
+ queue: {
1428
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
1429
+ },
1430
+ };
1431
+ }
1432
+
1433
+ describe('RenderGraph.drain (feat-20260612 M4 / w13)', () => {
1434
+ it('drain destroys every transient pool entry and clears the pool', () => {
1435
+ const probe: DrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1436
+ const g = new RenderGraph();
1437
+ g.addColorTarget('hdrColor', {
1438
+ format: 'rgba16float',
1439
+ size: { w: 64, h: 64 },
1440
+ sample: 1,
1441
+ usage: 0,
1442
+ });
1443
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
1444
+
1445
+ const r = g.compile({
1446
+ backendKind: 'webgpu',
1447
+ caps: drainMockCaps(),
1448
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1449
+ device: makeDrainDevice(probe) as any,
1450
+ });
1451
+ expect(r.ok).toBe(true);
1452
+ expect(probe.created).toBeGreaterThanOrEqual(1);
1453
+
1454
+ const baselineDestroyed = probe.destroyed;
1455
+ g.drain();
1456
+
1457
+ // Every pool entry got a destroyTexture call.
1458
+ expect(probe.destroyed - baselineDestroyed).toBe(probe.created);
1459
+ });
1460
+
1461
+ it('drain destroys every persistent texture entry and clears the map', () => {
1462
+ const probe: DrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1463
+ const g = new RenderGraph();
1464
+ g.addColorTarget('shadowDepth', {
1465
+ format: 'depth32float',
1466
+ size: { w: 64, h: 64 },
1467
+ lifetime: 'persistent',
1468
+ usage: 0,
1469
+ });
1470
+ g.addPass('shadow', { reads: [], writes: ['shadowDepth'] });
1471
+ const r = g.compile({
1472
+ backendKind: 'webgpu',
1473
+ caps: drainMockCaps(),
1474
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1475
+ device: makeDrainDevice(probe) as any,
1476
+ });
1477
+ expect(r.ok).toBe(true);
1478
+ expect(probe.created).toBe(1);
1479
+
1480
+ g.drain();
1481
+ expect(probe.destroyed).toBe(1);
1482
+ expect(() => g.drain()).not.toThrow();
1483
+ expect(probe.destroyed).toBe(1);
1484
+ });
1485
+
1486
+ it('drain is idempotent: a second drain after a cleared pool is a no-op', () => {
1487
+ const probe: DrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1488
+ const g = new RenderGraph();
1489
+ g.addColorTarget('hdrColor', {
1490
+ format: 'rgba16float',
1491
+ size: { w: 32, h: 32 },
1492
+ sample: 1,
1493
+ usage: 0,
1494
+ });
1495
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
1496
+
1497
+ const r = g.compile({
1498
+ backendKind: 'webgpu',
1499
+ caps: drainMockCaps(),
1500
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1501
+ device: makeDrainDevice(probe) as any,
1502
+ });
1503
+ expect(r.ok).toBe(true);
1504
+
1505
+ g.drain();
1506
+ const afterFirst = probe.destroyed;
1507
+
1508
+ // Second drain: pools already cleared, no further destroyTexture fires.
1509
+ expect(() => g.drain()).not.toThrow();
1510
+ expect(probe.destroyed).toBe(afterFirst);
1511
+ });
1512
+
1513
+ it('drain on a never-compiled graph is a safe no-op', () => {
1514
+ const g = new RenderGraph();
1515
+ expect(() => g.drain()).not.toThrow();
1516
+ });
1517
+ });
1518
+ }
1519
+
1520
+ {
1521
+ // --- from uniform-fallback.test.ts ---
1522
+ // ── Helpers ──────────────────────────────────────────────────────
1523
+
1524
+ function mockCaps(overrides: { storageBuffer?: boolean }) {
1525
+ return {
1526
+ backendKind: 'webgpu' as const,
1527
+ compute: true,
1528
+ storageBuffer: overrides.storageBuffer ?? true,
1529
+ storageTexture: false,
1530
+ timestampQuery: false,
1531
+ timestampPeriodNanoseconds: null,
1532
+ indirectDrawing: false,
1533
+ textureCompressionBc: false,
1534
+ textureCompressionEtc2: false,
1535
+ textureCompressionAstc: false,
1536
+ multiDrawIndirect: false,
1537
+ pushConstants: false,
1538
+ textureBindingArray: false,
1539
+ samplerAliasing: true,
1540
+ firstInstanceIndirect: false,
1541
+ rgba16floatRenderable: false,
1542
+ rg11b10ufloatRenderable: false,
1543
+ float32Filterable: false,
1544
+ maxColorAttachments: 8,
1545
+ };
1546
+ }
1547
+
1548
+ function resolvedTypeOf(
1549
+ graph: { resolvedBuffers: readonly { key: string; resolvedBufferType: string }[] },
1550
+ key: string,
1551
+ ): string | undefined {
1552
+ return graph.resolvedBuffers.find((b) => b.key === key)?.resolvedBufferType;
1553
+ }
1554
+
1555
+ // ── uniform fallback (AC-09 / D-6.1) ─────────────────────────────
1556
+ //
1557
+ // compile() resolves a `kind:'buffer'` resource declared with
1558
+ // `bufferRole='auto-storage-or-uniform'` to a concrete RHI binding type by
1559
+ // reading `caps.storageBuffer` -- mirroring the runtime pbr-pipeline cap
1560
+ // switch (research Finding 7), expressed in the RHI-pure graph layer. The two
1561
+ // branches MUST resolve to different types; if the resolution logic is
1562
+ // deleted, `resolvedBuffers` goes empty and these assertions fail.
1563
+
1564
+ describe('uniform fallback (AC-09 / D-6.1)', () => {
1565
+ it('resolves auto-storage-or-uniform to uniform when storageBuffer=false', () => {
1566
+ const g = new RenderGraph();
1567
+ g.addResource('lightBuf', {
1568
+ kind: 'buffer',
1569
+ lifetime: 'persistent',
1570
+ bufferRole: 'auto-storage-or-uniform',
1571
+ });
1572
+ g.addPass('mainPass', { reads: [], writes: ['lightBuf'] });
1573
+
1574
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: false }) });
1575
+
1576
+ expect(r.ok).toBe(true);
1577
+ if (r.ok) {
1578
+ expect(resolvedTypeOf(r.value, 'lightBuf')).toBe('uniform');
1579
+ }
1580
+ });
1581
+
1582
+ it('resolves auto-storage-or-uniform to read-only-storage when storageBuffer=true', () => {
1583
+ const g = new RenderGraph();
1584
+ g.addResource('lightBuf', {
1585
+ kind: 'buffer',
1586
+ lifetime: 'persistent',
1587
+ bufferRole: 'auto-storage-or-uniform',
1588
+ });
1589
+ g.addPass('mainPass', { reads: [], writes: ['lightBuf'] });
1590
+
1591
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: true }) });
1592
+
1593
+ expect(r.ok).toBe(true);
1594
+ if (r.ok) {
1595
+ expect(resolvedTypeOf(r.value, 'lightBuf')).toBe('read-only-storage');
1596
+ }
1597
+ });
1598
+
1599
+ it('the two branches resolve to different types (catches a removed cap switch)', () => {
1600
+ const build = () => {
1601
+ const g = new RenderGraph();
1602
+ g.addResource('lightBuf', {
1603
+ kind: 'buffer',
1604
+ lifetime: 'persistent',
1605
+ bufferRole: 'auto-storage-or-uniform',
1606
+ });
1607
+ g.addPass('mainPass', { reads: [], writes: ['lightBuf'] });
1608
+ return g;
1609
+ };
1610
+
1611
+ const noStorage = build().compile({
1612
+ backendKind: 'webgpu',
1613
+ caps: mockCaps({ storageBuffer: false }),
1614
+ });
1615
+ const withStorage = build().compile({
1616
+ backendKind: 'webgpu',
1617
+ caps: mockCaps({ storageBuffer: true }),
1618
+ });
1619
+
1620
+ expect(noStorage.ok && withStorage.ok).toBe(true);
1621
+ if (noStorage.ok && withStorage.ok) {
1622
+ const a = resolvedTypeOf(noStorage.value, 'lightBuf');
1623
+ const b = resolvedTypeOf(withStorage.value, 'lightBuf');
1624
+ expect(a).toBeDefined();
1625
+ expect(b).toBeDefined();
1626
+ expect(a).not.toBe(b);
1627
+ }
1628
+ });
1629
+
1630
+ it('bufferRole=uniform always resolves to uniform regardless of caps', () => {
1631
+ const build = () => {
1632
+ const g = new RenderGraph();
1633
+ g.addResource('camBuf', { kind: 'buffer', lifetime: 'persistent', bufferRole: 'uniform' });
1634
+ g.addPass('mainPass', { reads: [], writes: ['camBuf'] });
1635
+ return g;
1636
+ };
1637
+
1638
+ const withStorage = build().compile({
1639
+ backendKind: 'webgpu',
1640
+ caps: mockCaps({ storageBuffer: true }),
1641
+ });
1642
+ const noStorage = build().compile({
1643
+ backendKind: 'webgpu',
1644
+ caps: mockCaps({ storageBuffer: false }),
1645
+ });
1646
+
1647
+ expect(withStorage.ok && noStorage.ok).toBe(true);
1648
+ if (withStorage.ok && noStorage.ok) {
1649
+ expect(resolvedTypeOf(withStorage.value, 'camBuf')).toBe('uniform');
1650
+ expect(resolvedTypeOf(noStorage.value, 'camBuf')).toBe('uniform');
1651
+ }
1652
+ });
1653
+
1654
+ it('defaults an unset bufferRole to the auto cap switch', () => {
1655
+ const g = new RenderGraph();
1656
+ g.addResource('plainBuf', { kind: 'buffer', lifetime: 'persistent' });
1657
+ g.addPass('mainPass', { reads: [], writes: ['plainBuf'] });
1658
+
1659
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: false }) });
1660
+
1661
+ expect(r.ok).toBe(true);
1662
+ if (r.ok) {
1663
+ expect(resolvedTypeOf(r.value, 'plainBuf')).toBe('uniform');
1664
+ }
1665
+ });
1666
+
1667
+ it('omits texture resources from resolvedBuffers', () => {
1668
+ const g = new RenderGraph();
1669
+ g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
1670
+ g.addPass('mainPass', { reads: [], writes: ['hdrColor'] });
1671
+
1672
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: true }) });
1673
+
1674
+ expect(r.ok).toBe(true);
1675
+ if (r.ok) {
1676
+ expect(r.value.resolvedBuffers).toEqual([]);
1677
+ }
1678
+ });
1679
+ });
1680
+ }
1681
+
1682
+ {
1683
+ // --- feat-20260619-gpu-resource-ownership-symmetric-release-primitive M3 / w9: AC-09 resize drain ---
1684
+ //
1685
+ // Transient pool key includes dimensions (name:format:WxH:usage:sample). When
1686
+ // swap-chain size changes, old-dimension entries become stranded because the
1687
+ // new compile creates entries with new-dimension keys and the old ones are
1688
+ // never accessed again. drainTransient() must release all transient pool
1689
+ // textures on resize before reallocation.
1690
+ //
1691
+ // Coverage:
1692
+ // 1. Resize triggers drain of old transient entries (destroyTexture called per old entry).
1693
+ // 2. After resize drain, old keys are gone from the pool; new keys for new
1694
+ // dimensions exist.
1695
+ // 3. Non-resize recompile does NOT drain (pool entries survive when dimensions unchanged).
1696
+ // 4. drainTransient only touches transientPool, not persistentTextures.
1697
+
1698
+ function resizeDrainMockCaps() {
1699
+ return {
1700
+ backendKind: 'webgpu' as const,
1701
+ compute: true,
1702
+ storageBuffer: true,
1703
+ storageTexture: false,
1704
+ timestampQuery: false,
1705
+ timestampPeriodNanoseconds: null,
1706
+ indirectDrawing: false,
1707
+ textureCompressionBc: false,
1708
+ textureCompressionEtc2: false,
1709
+ textureCompressionAstc: false,
1710
+ multiDrawIndirect: false,
1711
+ pushConstants: false,
1712
+ textureBindingArray: false,
1713
+ samplerAliasing: true,
1714
+ firstInstanceIndirect: false,
1715
+ rgba16floatRenderable: false,
1716
+ rg11b10ufloatRenderable: false,
1717
+ float32Filterable: false,
1718
+ maxColorAttachments: 8,
1719
+ };
1720
+ }
1721
+
1722
+ interface ResizeDrainProbe {
1723
+ created: number;
1724
+ destroyed: number;
1725
+ destroyedHandles: object[];
1726
+ }
1727
+
1728
+ function makeResizeDrainDevice(probe: ResizeDrainProbe): Record<string, unknown> {
1729
+ return {
1730
+ createTexture: (_desc: unknown) => {
1731
+ probe.created += 1;
1732
+ return { ok: true as const, value: { __mock: `tex-${probe.created}` } };
1733
+ },
1734
+ createTextureView: (_tex: unknown, _desc: unknown) => {
1735
+ return { ok: true as const, value: { __mock: 'view' } };
1736
+ },
1737
+ destroyTexture: (tex: object) => {
1738
+ probe.destroyed += 1;
1739
+ probe.destroyedHandles.push(tex);
1740
+ return { ok: true as const, value: undefined };
1741
+ },
1742
+ queue: {
1743
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
1744
+ },
1745
+ };
1746
+ }
1747
+
1748
+ describe('transient pool resize drain (AC-09)', () => {
1749
+ it('resize enqueues old transient entries to pendingDestroy and reclaim destroys them', async () => {
1750
+ const probe: ResizeDrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1751
+ const g = new RenderGraph();
1752
+ g.addColorTarget('hdrColor', {
1753
+ format: 'rgba16float',
1754
+ size: 'swapchain',
1755
+ sample: 1,
1756
+ usage: 0,
1757
+ });
1758
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
1759
+
1760
+ // Compile at 64x64.
1761
+ g.setSwapChainSize(64, 64);
1762
+ const r1 = g.compile({
1763
+ backendKind: 'webgpu',
1764
+ caps: resizeDrainMockCaps(),
1765
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1766
+ device: makeResizeDrainDevice(probe) as any,
1767
+ });
1768
+ expect(r1.ok).toBe(true);
1769
+ const createdAfterFirst = probe.created;
1770
+ expect(createdAfterFirst).toBeGreaterThanOrEqual(1);
1771
+ const destroyedBeforeResize = probe.destroyed;
1772
+
1773
+ // Resize to 128x128 triggers recompile signal.
1774
+ const resizeDetected = g.setSwapChainSize(128, 128);
1775
+ expect(resizeDetected).toBe(true);
1776
+
1777
+ const r2 = g.compile({
1778
+ backendKind: 'webgpu',
1779
+ caps: resizeDrainMockCaps(),
1780
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1781
+ device: makeResizeDrainDevice(probe) as any,
1782
+ });
1783
+ expect(r2.ok).toBe(true);
1784
+
1785
+ // bug-20260622 D-1: old textures pushed to pendingDestroy, not
1786
+ // destroyed immediately. Resize compile should NOT call destroyTexture.
1787
+ const destroyedAtResize = probe.destroyed - destroyedBeforeResize;
1788
+ expect(destroyedAtResize).toBe(0);
1789
+
1790
+ // New textures were created for the new size.
1791
+ expect(probe.created).toBe(createdAfterFirst * 2);
1792
+
1793
+ // After reclaim, old textures are destroyed.
1794
+ await g.reclaimRetiredTransients();
1795
+ expect(probe.destroyed - destroyedBeforeResize).toBe(createdAfterFirst);
1796
+ });
1797
+
1798
+ it('non-resize recompile leaves transient pool intact (no spurious drain)', () => {
1799
+ const probe: ResizeDrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1800
+ const g = new RenderGraph();
1801
+ g.addColorTarget('hdrColor', {
1802
+ format: 'rgba16float',
1803
+ size: 'swapchain',
1804
+ sample: 1,
1805
+ usage: 0,
1806
+ });
1807
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
1808
+
1809
+ // Compile at 64x64 — no prior size set, so setSwapChainSize(64,64) returns true.
1810
+ g.setSwapChainSize(64, 64);
1811
+ const r1 = g.compile({
1812
+ backendKind: 'webgpu',
1813
+ caps: resizeDrainMockCaps(),
1814
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1815
+ device: makeResizeDrainDevice(probe) as any,
1816
+ });
1817
+ expect(r1.ok).toBe(true);
1818
+
1819
+ const destroyedAfterFirst = probe.destroyed;
1820
+ const createdAfterFirst = probe.created;
1821
+
1822
+ // Recompile without resize — setSwapChainSize(64,64) returns false.
1823
+ const resizeDetected = g.setSwapChainSize(64, 64);
1824
+ expect(resizeDetected).toBe(false);
1825
+
1826
+ const r2 = g.compile({
1827
+ backendKind: 'webgpu',
1828
+ caps: resizeDrainMockCaps(),
1829
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1830
+ device: makeResizeDrainDevice(probe) as any,
1831
+ });
1832
+ expect(r2.ok).toBe(true);
1833
+
1834
+ // No drain on non-resize: destroyTexture count unchanged, pool hit reused
1835
+ // the existing texture (no new createTexture calls).
1836
+ expect(probe.destroyed).toBe(destroyedAfterFirst);
1837
+ expect(probe.created).toBe(createdAfterFirst);
1838
+ });
1839
+
1840
+ it('drainTransient only clears transientPool, not persistentTextures', () => {
1841
+ const probe: ResizeDrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1842
+ const g = new RenderGraph();
1843
+ g.addColorTarget('hdrColor', {
1844
+ format: 'rgba16float',
1845
+ size: 'swapchain',
1846
+ sample: 1,
1847
+ usage: 0,
1848
+ });
1849
+ g.addColorTarget('history', {
1850
+ format: 'rgba16float',
1851
+ size: { w: 32, h: 32 },
1852
+ lifetime: 'persistent',
1853
+ usage: 0,
1854
+ });
1855
+ g.addPass('main', { reads: [], writes: ['hdrColor', 'history'] });
1856
+
1857
+ g.setSwapChainSize(64, 64);
1858
+ const r1 = g.compile({
1859
+ backendKind: 'webgpu',
1860
+ caps: resizeDrainMockCaps(),
1861
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1862
+ device: makeResizeDrainDevice(probe) as any,
1863
+ });
1864
+ expect(r1.ok).toBe(true);
1865
+
1866
+ // Snapshot persistentTextures before resize.
1867
+ // biome-ignore lint/suspicious/noExplicitAny: internal pool access in unit test
1868
+ const ptexBefore = (g as any).persistentTextures as Map<string, unknown>;
1869
+ const ptexSizeBefore = ptexBefore.size;
1870
+ const historyBefore = g.getColorTargetTexture('history');
1871
+
1872
+ // Resize.
1873
+ g.setSwapChainSize(128, 128);
1874
+ const r2 = g.compile({
1875
+ backendKind: 'webgpu',
1876
+ caps: resizeDrainMockCaps(),
1877
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1878
+ device: makeResizeDrainDevice(probe) as any,
1879
+ });
1880
+ expect(r2.ok).toBe(true);
1881
+
1882
+ // Persistent textures untouched by drainTransient.
1883
+ expect(ptexBefore.size).toBe(ptexSizeBefore);
1884
+ expect(g.getColorTargetTexture('history')).toBe(historyBefore);
1885
+ expect(probe.created).toBe(3);
1886
+ });
1887
+ });
1888
+ }
1889
+
1890
+ {
1891
+ // --- bug-20260622-hdrcolor-transient-pool-destroy-in-flight M1: red baseline — deferred-destroy fence ---
1892
+ //
1893
+ // The existing AC-09 test asserts "resize triggers drainTransient which
1894
+ // destroys old textures immediately". This is the bug: those textures are
1895
+ // still referenced by the previous frame's in-flight command buffer.
1896
+ //
1897
+ // The fix adds a pendingDestroy queue (D-1) and a reclaimRetiredTransients
1898
+ // method (D-2) that defers actual destroyTexture until
1899
+ // device.queue.onSubmittedWorkDone() resolves.
1900
+ //
1901
+ // This test (M1 RED baseline) asserts the NEW expected behaviour:
1902
+ // 1. Resize compile pushes old textures into pendingDestroy, does NOT
1903
+ // call destroyTexture immediately.
1904
+ // 2. reclaimRetiredTransients() triggers onSubmittedWorkDone then
1905
+ // destroys the pending items.
1906
+ //
1907
+ // Currently RED — drainTransient destroys synchronously, so
1908
+ // destroyedAtResize > 0 (this test asserts destroyedAtResize === 0).
1909
+
1910
+ function deferredMockCaps() {
1911
+ return {
1912
+ backendKind: 'webgpu' as const,
1913
+ compute: true,
1914
+ storageBuffer: true,
1915
+ storageTexture: false,
1916
+ timestampQuery: false,
1917
+ timestampPeriodNanoseconds: null,
1918
+ indirectDrawing: false,
1919
+ textureCompressionBc: false,
1920
+ textureCompressionEtc2: false,
1921
+ textureCompressionAstc: false,
1922
+ multiDrawIndirect: false,
1923
+ pushConstants: false,
1924
+ textureBindingArray: false,
1925
+ samplerAliasing: true,
1926
+ firstInstanceIndirect: false,
1927
+ rgba16floatRenderable: false,
1928
+ rg11b10ufloatRenderable: false,
1929
+ float32Filterable: false,
1930
+ maxColorAttachments: 8,
1931
+ };
1932
+ }
1933
+
1934
+ interface DeferredProbe {
1935
+ created: number;
1936
+ destroyed: number;
1937
+ destroyedHandles: object[];
1938
+ }
1939
+
1940
+ function makeDeferredDevice(probe: DeferredProbe): Record<string, unknown> {
1941
+ return {
1942
+ createTexture: (_desc: unknown) => {
1943
+ probe.created += 1;
1944
+ return { ok: true as const, value: { __mock: `tex-${probe.created}` } };
1945
+ },
1946
+ createTextureView: (_tex: unknown, _desc: unknown) => {
1947
+ return { ok: true as const, value: { __mock: 'view' } };
1948
+ },
1949
+ destroyTexture: (tex: object) => {
1950
+ probe.destroyed += 1;
1951
+ probe.destroyedHandles.push(tex);
1952
+ return { ok: true as const, value: undefined };
1953
+ },
1954
+ queue: {
1955
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
1956
+ },
1957
+ };
1958
+ }
1959
+
1960
+ describe('deferred-destroy: resize enqueues pendingDestroy, not immediate destroy (AC-03 RED)', () => {
1961
+ it('resize compile pushes old transient textures into pendingDestroy (destroy count = 0 at resize)', () => {
1962
+ const probe: DeferredProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1963
+ const g = new RenderGraph();
1964
+ g.addColorTarget('hdrColor', {
1965
+ format: 'rgba16float',
1966
+ size: 'swapchain',
1967
+ sample: 1,
1968
+ usage: 0,
1969
+ });
1970
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
1971
+
1972
+ // Compile at 64x64.
1973
+ g.setSwapChainSize(64, 64);
1974
+ const r1 = g.compile({
1975
+ backendKind: 'webgpu',
1976
+ caps: deferredMockCaps(),
1977
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1978
+ device: makeDeferredDevice(probe) as any,
1979
+ });
1980
+ expect(r1.ok).toBe(true);
1981
+ const createdAfterFirst = probe.created;
1982
+ expect(createdAfterFirst).toBeGreaterThanOrEqual(1);
1983
+
1984
+ const destroyedBeforeResize = probe.destroyed;
1985
+
1986
+ // Resize to 128x128.
1987
+ const resizeDetected = g.setSwapChainSize(128, 128);
1988
+ expect(resizeDetected).toBe(true);
1989
+
1990
+ const r2 = g.compile({
1991
+ backendKind: 'webgpu',
1992
+ caps: deferredMockCaps(),
1993
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1994
+ device: makeDeferredDevice(probe) as any,
1995
+ });
1996
+ expect(r2.ok).toBe(true);
1997
+
1998
+ // AC-03 RED: after resize compile, old textures should NOT be destroyed
1999
+ // yet — they are in pendingDestroy queue, waiting for GPU retirement.
2000
+ // Currently RED because drainTransient destroys synchronously.
2001
+ const destroyedAtResize = probe.destroyed - destroyedBeforeResize;
2002
+ expect(destroyedAtResize).toBe(0);
2003
+ });
2004
+
2005
+ it('pendingDestroy is accessible as internal field after resize', () => {
2006
+ const probe: DeferredProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
2007
+ const g = new RenderGraph();
2008
+ g.addColorTarget('hdrColor', {
2009
+ format: 'rgba16float',
2010
+ size: 'swapchain',
2011
+ sample: 1,
2012
+ usage: 0,
2013
+ });
2014
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
2015
+
2016
+ g.setSwapChainSize(64, 64);
2017
+ g.compile({
2018
+ backendKind: 'webgpu',
2019
+ caps: deferredMockCaps(),
2020
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2021
+ device: makeDeferredDevice(probe) as any,
2022
+ });
2023
+
2024
+ g.setSwapChainSize(128, 128);
2025
+ g.compile({
2026
+ backendKind: 'webgpu',
2027
+ caps: deferredMockCaps(),
2028
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2029
+ device: makeDeferredDevice(probe) as any,
2030
+ });
2031
+
2032
+ // After resize, pendingDestroy should contain the old transient textures.
2033
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
2034
+ const pending = (g as any).pendingDestroy as unknown[];
2035
+ expect(Array.isArray(pending)).toBe(true);
2036
+ expect(pending.length).toBeGreaterThanOrEqual(1);
2037
+ });
2038
+
2039
+ it('reclaimRetiredTransients destroys pending items after onSubmittedWorkDone resolves', async () => {
2040
+ const probe: DeferredProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
2041
+ const g = new RenderGraph();
2042
+ g.addColorTarget('hdrColor', {
2043
+ format: 'rgba16float',
2044
+ size: 'swapchain',
2045
+ sample: 1,
2046
+ usage: 0,
2047
+ });
2048
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
2049
+
2050
+ g.setSwapChainSize(64, 64);
2051
+ g.compile({
2052
+ backendKind: 'webgpu',
2053
+ caps: deferredMockCaps(),
2054
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2055
+ device: makeDeferredDevice(probe) as any,
2056
+ });
2057
+
2058
+ const destroyedBeforeResize = probe.destroyed;
2059
+
2060
+ g.setSwapChainSize(128, 128);
2061
+ g.compile({
2062
+ backendKind: 'webgpu',
2063
+ caps: deferredMockCaps(),
2064
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2065
+ device: makeDeferredDevice(probe) as any,
2066
+ });
2067
+
2068
+ // At resize: no destroy yet.
2069
+ expect(probe.destroyed - destroyedBeforeResize).toBe(0);
2070
+
2071
+ // Reclaim: destroys pending items after queue.onSubmittedWorkDone resolves.
2072
+ // biome-ignore lint/suspicious/noExplicitAny: method not yet public
2073
+ await (g as any).reclaimRetiredTransients();
2074
+
2075
+ // After reclaim, all pending items should be destroyed.
2076
+ expect(probe.destroyed - destroyedBeforeResize).toBeGreaterThanOrEqual(1);
2077
+
2078
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
2079
+ const pending = (g as any).pendingDestroy as unknown[];
2080
+ expect(pending.length).toBe(0);
2081
+ });
2082
+ });
2083
+ }
2084
+
2085
+ {
2086
+ // --- feat-20260619-gpu-resource-ownership-symmetric-release-primitive M3 / w10: AC-08 same-key destroy ---
2087
+ //
2088
+ // Defensive: when transientPool.set(key, pooled) overwrites an existing key,
2089
+ // the old PooledTexture.texture must be destroyed first (symmetric release).
2090
+ // Currently this code path is unreachable because set() only follows a get()
2091
+ // miss; the test exercises the guard through a private helper setTransientEntry
2092
+ // that encapsulates the has-check + destroy + set logic (added in w11).
2093
+ //
2094
+ // The guard (w11, inside allocateColorTargets) is:
2095
+ // this.setTransientEntry(key, pooled)
2096
+ // where setTransientEntry does:
2097
+ // if (this.transientPool.has(key)) {
2098
+ // device.destroyTexture(this.transientPool.get(key)!.texture as Texture);
2099
+ // }
2100
+ // this.transientPool.set(key, pooled);
2101
+ //
2102
+ // This test directly calls setTransientEntry (internal, accessed via 'as any')
2103
+ // to verify the guard behavior. In RED phase (w10, no w11 yet), the method
2104
+ // does not exist — the test fails. In GREEN phase (w11), the method exists
2105
+ // and destroys the old texture before overwriting.
2106
+
2107
+ function sameKeyMockCaps() {
2108
+ return {
2109
+ backendKind: 'webgpu' as const,
2110
+ compute: true,
2111
+ storageBuffer: true,
2112
+ storageTexture: false,
2113
+ timestampQuery: false,
2114
+ timestampPeriodNanoseconds: null,
2115
+ indirectDrawing: false,
2116
+ textureCompressionBc: false,
2117
+ textureCompressionEtc2: false,
2118
+ textureCompressionAstc: false,
2119
+ multiDrawIndirect: false,
2120
+ pushConstants: false,
2121
+ textureBindingArray: false,
2122
+ samplerAliasing: true,
2123
+ firstInstanceIndirect: false,
2124
+ rgba16floatRenderable: false,
2125
+ rg11b10ufloatRenderable: false,
2126
+ float32Filterable: false,
2127
+ maxColorAttachments: 8,
2128
+ };
2129
+ }
2130
+
2131
+ describe('transient pool same-key destroy (AC-08)', () => {
2132
+ it('old pooled texture is enqueued to pendingDestroy on same-key set overwrite', () => {
2133
+ const destroyedHandles: object[] = [];
2134
+ const mockDevice = {
2135
+ createTexture: (_desc: unknown) => ({
2136
+ ok: true as const,
2137
+ value: { __mock: `tex-${Date.now()}` },
2138
+ }),
2139
+ createTextureView: (_tex: unknown, _desc: unknown) => ({
2140
+ ok: true as const,
2141
+ value: { __mock: 'view' },
2142
+ }),
2143
+ destroyTexture: (tex: object) => {
2144
+ destroyedHandles.push(tex);
2145
+ return { ok: true as const, value: undefined };
2146
+ },
2147
+ queue: {
2148
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
2149
+ },
2150
+ };
2151
+
2152
+ const g = new RenderGraph();
2153
+ g.addColorTarget('hdrColor', {
2154
+ format: 'rgba16float',
2155
+ size: { w: 64, h: 64 },
2156
+ sample: 1,
2157
+ usage: 0,
2158
+ });
2159
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
2160
+
2161
+ // Compile to set lastDevice and populate transientPool.
2162
+ const r = g.compile({
2163
+ backendKind: 'webgpu',
2164
+ caps: sameKeyMockCaps(),
2165
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2166
+ device: mockDevice as any,
2167
+ });
2168
+ expect(r.ok).toBe(true);
2169
+
2170
+ // Access internal transient pool to get an existing entry.
2171
+ // biome-ignore lint/suspicious/noExplicitAny: internal pool access in unit test
2172
+ const pool = (g as any).transientPool as Map<string, { texture: object; view: object }>;
2173
+ const entries = [...pool.entries()];
2174
+ expect(entries.length).toBeGreaterThanOrEqual(1);
2175
+ // biome-ignore lint/style/noNonNullAssertion: guarded by length check above
2176
+ const [key, oldEntry] = entries[0]!;
2177
+ const oldTexture = oldEntry.texture;
2178
+
2179
+ const destroyedBeforeSet = destroyedHandles.length;
2180
+
2181
+ // Call setTransientEntry to simulate same-key overwrite.
2182
+ // bug-20260622 D-1: old texture pushed to pendingDestroy, not
2183
+ // immediately destroyed.
2184
+ // biome-ignore lint/suspicious/noExplicitAny: internal method access in unit test
2185
+ (g as any).setTransientEntry(key, {
2186
+ texture: { __mock: 'replacement-tex' },
2187
+ view: { __mock: 'replacement-view' },
2188
+ });
2189
+
2190
+ // No immediate destroy — entry is now in pendingDestroy queue.
2191
+ expect(destroyedHandles.length).toBe(destroyedBeforeSet);
2192
+
2193
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
2194
+ const pending = (g as any).pendingDestroy as { texture: object; view: object }[];
2195
+ expect(pending.length).toBeGreaterThanOrEqual(1);
2196
+ expect(pending.some((p) => p.texture === oldTexture)).toBe(true);
2197
+ });
2198
+ });
2199
+ }
2200
+
2201
+ {
2202
+ // --- w15: B-2 recover() clears pendingDestroy (feat-20260622-s5 M3) ---
2203
+ //
2204
+ // The recover() rebuild path (createRenderer M3) tears down the old device
2205
+ // and requests a fresh one. The render-graph's pendingDestroy queue still
2206
+ // holds PooledTexture handles minted against the OLD device; calling
2207
+ // destroyTexture on them against the NEW device is meaningless (and may
2208
+ // throw). recover() therefore drops the queue via clearPendingDestroy()
2209
+ // instead of draining it (B-2 / B-AC-02, plan-strategy D-5). This mirrors
2210
+ // the existing null-device fast path (`pendingDestroy.length = 0`) but is a
2211
+ // public method recover() can call directly.
2212
+
2213
+ function clearPendingCaps() {
2214
+ return {
2215
+ backendKind: 'webgpu' as const,
2216
+ compute: true,
2217
+ storageBuffer: true,
2218
+ storageTexture: false,
2219
+ timestampQuery: false,
2220
+ timestampPeriodNanoseconds: null,
2221
+ indirectDrawing: false,
2222
+ textureCompressionBc: false,
2223
+ textureCompressionEtc2: false,
2224
+ textureCompressionAstc: false,
2225
+ multiDrawIndirect: false,
2226
+ pushConstants: false,
2227
+ textureBindingArray: false,
2228
+ samplerAliasing: true,
2229
+ firstInstanceIndirect: false,
2230
+ rgba16floatRenderable: false,
2231
+ rg11b10ufloatRenderable: false,
2232
+ float32Filterable: false,
2233
+ maxColorAttachments: 8,
2234
+ };
2235
+ }
2236
+
2237
+ // --- w20: B-1 destroyTexture error tolerance (feat-20260622-s5 M4) ---
2238
+ //
2239
+ // The render-graph's drain() and reclaimRetiredTransients() docstrings
2240
+ // claim per-handle destroy errors are tolerated (swallow-and-continue),
2241
+ // but the current implementation uses bare for-loops with no try/catch.
2242
+ // B-1 fixes the gap (plan-strategy D-4 / PD3): any single
2243
+ // device.destroyTexture failure must not interrupt the destroy chain
2244
+ // for subsequent handles. Test coverage (w20, B-AC-01):
2245
+ // 1. reclaimRetiredTransients snapshot loop — error on item 0,
2246
+ // items 1..N-1 still destroyed, queue empty after.
2247
+ // 2. drain() loops (transient / pendingDestroy / persistent) — same
2248
+ // per-handle tolerance policy via a shared mock device.
2249
+
2250
+ function b1ToleranceCaps() {
2251
+ return {
2252
+ backendKind: 'webgpu' as const,
2253
+ compute: true,
2254
+ storageBuffer: true,
2255
+ storageTexture: false,
2256
+ timestampQuery: false,
2257
+ timestampPeriodNanoseconds: null,
2258
+ indirectDrawing: false,
2259
+ textureCompressionBc: false,
2260
+ textureCompressionEtc2: false,
2261
+ textureCompressionAstc: false,
2262
+ multiDrawIndirect: false,
2263
+ pushConstants: false,
2264
+ textureBindingArray: false,
2265
+ samplerAliasing: true,
2266
+ firstInstanceIndirect: false,
2267
+ rgba16floatRenderable: false,
2268
+ rg11b10ufloatRenderable: false,
2269
+ float32Filterable: false,
2270
+ maxColorAttachments: 8,
2271
+ };
2272
+ }
2273
+
2274
+ interface B1Probe {
2275
+ created: number;
2276
+ destroyed: number;
2277
+ destroyedHandles: object[];
2278
+ /** If non-negative, destroyTexture throws on this 0-based invocation. */
2279
+ throwOnCall: number;
2280
+ destroyCallCount: number;
2281
+ }
2282
+
2283
+ function makeB1Device(probe: B1Probe): {
2284
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2285
+ [k: string]: any;
2286
+ } {
2287
+ return {
2288
+ createTexture: (_desc: unknown) => {
2289
+ probe.created += 1;
2290
+ const handle = { __mock: `tex-${probe.created}` };
2291
+ return { ok: true as const, value: handle };
2292
+ },
2293
+ createTextureView: (_tex: unknown, _desc: unknown) => {
2294
+ return { ok: true as const, value: { __mock: 'view' } };
2295
+ },
2296
+ destroyTexture: (tex: object) => {
2297
+ probe.destroyCallCount += 1;
2298
+ if (probe.destroyCallCount - 1 === probe.throwOnCall) {
2299
+ throw new Error('mock destroyTexture error (simulated)');
2300
+ }
2301
+ probe.destroyed += 1;
2302
+ probe.destroyedHandles.push(tex);
2303
+ return { ok: true as const, value: undefined };
2304
+ },
2305
+ queue: {
2306
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
2307
+ },
2308
+ };
2309
+ }
2310
+
2311
+ describe('B-1 destroyTexture error tolerance (w20)', () => {
2312
+ it('reclaimRetiredTransients: item-0 error does not stop item 1..N from being destroyed', async () => {
2313
+ const probe: B1Probe = {
2314
+ created: 0,
2315
+ destroyed: 0,
2316
+ destroyedHandles: [],
2317
+ throwOnCall: 0,
2318
+ destroyCallCount: 0,
2319
+ };
2320
+ const mockDevice = makeB1Device(probe);
2321
+ const g = new RenderGraph();
2322
+ g.addColorTarget('hdrColor', {
2323
+ format: 'rgba16float',
2324
+ size: { w: 64, h: 64 },
2325
+ sample: 1,
2326
+ usage: 0,
2327
+ });
2328
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
2329
+
2330
+ const r = g.compile({
2331
+ backendKind: 'webgpu',
2332
+ caps: b1ToleranceCaps(),
2333
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2334
+ device: mockDevice as any,
2335
+ });
2336
+ expect(r.ok).toBe(true);
2337
+
2338
+ // Populate pendingDestroy with >= 3 entries by replacing the
2339
+ // transient pool entry three times (setTransientEntry pushes the
2340
+ // old one to pendingDestroy each time).
2341
+ // biome-ignore lint/suspicious/noExplicitAny: internal pool + pending access in unit test
2342
+ const pool = (g as any).transientPool as Map<string, { texture: object; view: object }>;
2343
+ for (let i = 0; i < 3; i++) {
2344
+ const [key] = [...pool.entries()][0] as [string, { texture: object }];
2345
+ // biome-ignore lint/suspicious/noExplicitAny: internal method access
2346
+ (g as any).setTransientEntry(key, {
2347
+ texture: { __mock: `entry-${i}` },
2348
+ view: { __mock: 'view' },
2349
+ });
2350
+ }
2351
+
2352
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access
2353
+ const pending = (g as any).pendingDestroy as { texture: object; view: object }[];
2354
+ expect(pending.length).toBe(3);
2355
+
2356
+ // reclaimRetiredTransients takes a snapshot and calls
2357
+ // device.destroyTexture on each. Item 0 throws; items 1,2 must
2358
+ // still be destroyed (swallow-and-continue). The queue must be
2359
+ // empty afterwards.
2360
+ await expect(g.reclaimRetiredTransients()).resolves.toBeUndefined();
2361
+
2362
+ // After reclaim: queue drained (items consumed from the snapshot;
2363
+ // snapshot was spliced before the loop, so pendingDestroy is empty
2364
+ // because the loop consumed the snapshot array in-place).
2365
+ expect(pending.length).toBe(0);
2366
+
2367
+ // Item 0 threw, but items 1,2 succeeded -> at least 2 destroyed.
2368
+ expect(probe.destroyed).toBe(2);
2369
+ // destroyTexture was called 3 times (item 0 threw, items 1,2 succeeded).
2370
+ expect(probe.destroyCallCount).toBe(3);
2371
+ });
2372
+
2373
+ it('drain: transient-pool destroy error on item 0 does not stop subsequent items from being destroyed', () => {
2374
+ const probe: B1Probe = {
2375
+ created: 0,
2376
+ destroyed: 0,
2377
+ destroyedHandles: [],
2378
+ throwOnCall: 0,
2379
+ destroyCallCount: 0,
2380
+ };
2381
+ const mockDevice = makeB1Device(probe);
2382
+ const g = new RenderGraph();
2383
+ g.addColorTarget('a', {
2384
+ format: 'rgba16float',
2385
+ size: { w: 32, h: 32 },
2386
+ sample: 1,
2387
+ usage: 0,
2388
+ });
2389
+ g.addColorTarget('b', {
2390
+ format: 'rgba8unorm',
2391
+ size: { w: 32, h: 32 },
2392
+ sample: 1,
2393
+ usage: 0,
2394
+ });
2395
+ g.addPass('main', { reads: [], writes: ['a', 'b'] });
2396
+
2397
+ const r = g.compile({
2398
+ backendKind: 'webgpu',
2399
+ caps: b1ToleranceCaps(),
2400
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2401
+ device: mockDevice as any,
2402
+ });
2403
+ expect(r.ok).toBe(true);
2404
+
2405
+ // Both color targets created transient pool entries.
2406
+ // drain() walks transientPool.values(), then pendingDestroy, then
2407
+ // persistentTextures. With throwOnCall=0, the very first
2408
+ // destroyTexture (for the first transient pool entry) throws.
2409
+ // Subsequent entries must still be destroyed.
2410
+ //
2411
+ // We can't use compile+resize to enqueue pendingDestroy because
2412
+ // the recompile with the same device would use the same pool.
2413
+ // Instead we exercise the transient pool destroy path directly
2414
+ // via drain(), which walks all 3 loops. The transient pool has 2
2415
+ // entries (a,b). Item 0 throws, item 1 succeeds.
2416
+ const destroyedBefore = probe.destroyed;
2417
+ g.drain();
2418
+
2419
+ // Item 1 was still destroyed after item 0 threw.
2420
+ // Transient pool has 2 entries -> at least 1 destroyed (item 0
2421
+ // threw, item 1 succeeded). pendingDestroy and persistent were
2422
+ // empty/cleared so no further calls.
2423
+ expect(probe.destroyed - destroyedBefore).toBe(1);
2424
+ expect(probe.destroyCallCount - destroyedBefore).toBe(2);
2425
+ });
2426
+
2427
+ it('drain: pendingDestroy teardown error on item 0 does not stop subsequent items', () => {
2428
+ const probe: B1Probe = {
2429
+ created: 0,
2430
+ destroyed: 0,
2431
+ destroyedHandles: [],
2432
+ throwOnCall: 2, // throw on the 3rd destroy call (0-based)
2433
+ // transient pool has 2 entries -> calls 0,1
2434
+ // pendingDestroy has 3 entries -> calls 2,3,4
2435
+ // throwOnCall=2 -> error on pendingDestroy item 0
2436
+ destroyCallCount: 0,
2437
+ };
2438
+ const mockDevice = makeB1Device(probe);
2439
+ const g = new RenderGraph();
2440
+ g.addColorTarget('a', {
2441
+ format: 'rgba16float',
2442
+ size: { w: 32, h: 32 },
2443
+ sample: 1,
2444
+ usage: 0,
2445
+ });
2446
+ g.addPass('main', { reads: [], writes: ['a'] });
2447
+
2448
+ const r = g.compile({
2449
+ backendKind: 'webgpu',
2450
+ caps: b1ToleranceCaps(),
2451
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2452
+ device: mockDevice as any,
2453
+ });
2454
+ expect(r.ok).toBe(true);
2455
+
2456
+ // Push 3 entries into pendingDestroy.
2457
+ for (let i = 0; i < 3; i++) {
2458
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access
2459
+ (g as any).pendingDestroy.push({ texture: { __mock: `pd-${i}` }, view: { __mock: 'v' } });
2460
+ }
2461
+
2462
+ const destroyedBefore = probe.destroyed;
2463
+ g.drain();
2464
+
2465
+ // transient pool (1 entry) -> calls 0 (ok)
2466
+ // pendingDestroy (3 entries) -> call 1 throws (item 0 of pendingDestroy),
2467
+ // calls 2,3 succeed (items 1,2 of pendingDestroy)
2468
+ // persistent -> empty, nothing
2469
+ // Total: call 0 ok + call 1 throw + call 2 ok + call 3 ok = 3 successes
2470
+ expect(probe.destroyed - destroyedBefore).toBe(3);
2471
+ // 4 total calls attempted
2472
+ expect(probe.destroyCallCount - destroyedBefore).toBe(4);
2473
+ // pendingDestroy cleared
2474
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access
2475
+ expect(((g as any).pendingDestroy as unknown[]).length).toBe(0);
2476
+ });
2477
+ });
2478
+
2479
+ describe('RenderGraph.clearPendingDestroy (B-2 / B-AC-02)', () => {
2480
+ it('clears the pendingDestroy queue without calling destroyTexture on stale handles', () => {
2481
+ const destroyedHandles: object[] = [];
2482
+ const mockDevice = {
2483
+ createTexture: (_desc: unknown) => ({
2484
+ ok: true as const,
2485
+ value: { __mock: `tex-${Date.now()}-${Math.random()}` },
2486
+ }),
2487
+ createTextureView: (_tex: unknown, _desc: unknown) => ({
2488
+ ok: true as const,
2489
+ value: { __mock: 'view' },
2490
+ }),
2491
+ destroyTexture: (tex: object) => {
2492
+ destroyedHandles.push(tex);
2493
+ return { ok: true as const, value: undefined };
2494
+ },
2495
+ queue: {
2496
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
2497
+ },
2498
+ };
2499
+
2500
+ const g = new RenderGraph();
2501
+ g.addColorTarget('hdrColor', {
2502
+ format: 'rgba16float',
2503
+ size: { w: 64, h: 64 },
2504
+ sample: 1,
2505
+ usage: 0,
2506
+ });
2507
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
2508
+
2509
+ const r = g.compile({
2510
+ backendKind: 'webgpu',
2511
+ caps: clearPendingCaps(),
2512
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2513
+ device: mockDevice as any,
2514
+ });
2515
+ expect(r.ok).toBe(true);
2516
+
2517
+ // Populate pendingDestroy with a stale entry (simulating a resize-time
2518
+ // transient retirement that has not yet been reclaimed).
2519
+ // biome-ignore lint/suspicious/noExplicitAny: internal pool access in unit test
2520
+ const pool = (g as any).transientPool as Map<string, { texture: object; view: object }>;
2521
+ const [key, oldEntry] = [...pool.entries()][0] as [string, { texture: object; view: object }];
2522
+ // biome-ignore lint/suspicious/noExplicitAny: internal method access in unit test
2523
+ (g as any).setTransientEntry(key, {
2524
+ texture: { __mock: 'replacement-tex' },
2525
+ view: { __mock: 'replacement-view' },
2526
+ });
2527
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
2528
+ const pending = (g as any).pendingDestroy as { texture: object; view: object }[];
2529
+ expect(pending.length).toBeGreaterThanOrEqual(1);
2530
+ expect(pending.some((p) => p.texture === oldEntry.texture)).toBe(true);
2531
+
2532
+ const destroyedBefore = destroyedHandles.length;
2533
+
2534
+ // B-2: clearPendingDestroy empties the queue WITHOUT calling
2535
+ // destroyTexture (stale handles belong to the lost device).
2536
+ (g as { clearPendingDestroy: () => void }).clearPendingDestroy();
2537
+
2538
+ expect(pending.length).toBe(0);
2539
+ // No destroyTexture call on the stale device handles.
2540
+ expect(destroyedHandles.length).toBe(destroyedBefore);
2541
+ });
2542
+
2543
+ it('clearPendingDestroy on an empty queue is a safe no-op', () => {
2544
+ const g = new RenderGraph();
2545
+ expect(() => (g as { clearPendingDestroy: () => void }).clearPendingDestroy()).not.toThrow();
2546
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
2547
+ const pending = (g as any).pendingDestroy as unknown[];
2548
+ expect(pending.length).toBe(0);
2549
+ });
2550
+ });
2551
+ }