@forgeax/engine-render-graph 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +184 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/graph-pass-kind-owner.test-d.d.ts +2 -0
  5. package/dist/__tests__/graph-pass-kind-owner.test-d.d.ts.map +1 -0
  6. package/dist/__tests__/observation-lifetime-state-owner.test-d.d.ts +2 -0
  7. package/dist/__tests__/observation-lifetime-state-owner.test-d.d.ts.map +1 -0
  8. package/dist/__tests__/observation.unit.test.d.ts +2 -0
  9. package/dist/__tests__/observation.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/render-graph-alias-source.unit.test.d.ts +2 -0
  11. package/dist/__tests__/render-graph-alias-source.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/render-graph-builder.unit.test.d.ts +2 -0
  13. package/dist/__tests__/render-graph-builder.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/render-graph-capability-owner.test-d.d.ts +2 -0
  15. package/dist/__tests__/render-graph-capability-owner.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/render-graph-errors.test-d.d.ts +2 -0
  17. package/dist/__tests__/render-graph-errors.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/render-graph-regressions.unit.test.d.ts +2 -0
  19. package/dist/__tests__/render-graph-regressions.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/render-graph-rhi-null.integration.test.d.ts +2 -0
  21. package/dist/__tests__/render-graph-rhi-null.integration.test.d.ts.map +1 -0
  22. package/dist/__tests__/render-graph.unit.test.d.ts +2 -0
  23. package/dist/__tests__/render-graph.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/resource-declaration-collision.test.d.ts +2 -0
  25. package/dist/__tests__/resource-declaration-collision.test.d.ts.map +1 -0
  26. package/dist/__tests__/resource-kind-owner.test-d.d.ts +2 -0
  27. package/dist/__tests__/resource-kind-owner.test-d.d.ts.map +1 -0
  28. package/dist/builder.d.ts +42 -0
  29. package/dist/builder.d.ts.map +1 -0
  30. package/dist/compiled-graph.d.ts +23 -0
  31. package/dist/compiled-graph.d.ts.map +1 -0
  32. package/dist/errors.d.ts +148 -0
  33. package/dist/errors.d.ts.map +1 -0
  34. package/dist/graph.d.ts +404 -0
  35. package/dist/graph.d.ts.map +1 -0
  36. package/dist/index.d.ts +8 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.mjs +2256 -0
  39. package/dist/index.mjs.map +1 -0
  40. package/dist/kernel-internal.d.ts +84 -0
  41. package/dist/kernel-internal.d.ts.map +1 -0
  42. package/dist/observation.d.ts +38 -0
  43. package/dist/observation.d.ts.map +1 -0
  44. package/dist/pass-registry.d.ts +12 -0
  45. package/dist/pass-registry.d.ts.map +1 -0
  46. package/dist/pipeline/__tests__/color-value-domain.test.d.ts +2 -0
  47. package/dist/pipeline/__tests__/color-value-domain.test.d.ts.map +1 -0
  48. package/dist/pipeline/color-value-domain.d.ts +35 -0
  49. package/dist/pipeline/color-value-domain.d.ts.map +1 -0
  50. package/dist/resource-registry.d.ts +61 -0
  51. package/dist/resource-registry.d.ts.map +1 -0
  52. package/dist/types.d.ts +171 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/package.json +61 -0
  55. package/src/__tests__/graph-pass-kind-owner.test-d.ts +35 -0
  56. package/src/__tests__/observation-lifetime-state-owner.test-d.ts +39 -0
  57. package/src/__tests__/observation.unit.test.ts +103 -0
  58. package/src/__tests__/render-graph-alias-source.unit.test.ts +118 -0
  59. package/src/__tests__/render-graph-builder.unit.test.ts +664 -0
  60. package/src/__tests__/render-graph-capability-owner.test-d.ts +32 -0
  61. package/src/__tests__/render-graph-errors.test-d.ts +169 -0
  62. package/src/__tests__/render-graph-regressions.unit.test.ts +126 -0
  63. package/src/__tests__/render-graph-rhi-null.integration.test.ts +86 -0
  64. package/src/__tests__/render-graph.unit.test.ts +2549 -0
  65. package/src/__tests__/resource-declaration-collision.test.ts +151 -0
  66. package/src/__tests__/resource-kind-owner.test-d.ts +38 -0
  67. package/src/builder.ts +1009 -0
  68. package/src/compiled-graph.ts +383 -0
  69. package/src/errors.ts +205 -0
  70. package/src/graph.ts +1270 -0
  71. package/src/index.ts +97 -0
  72. package/src/kernel-internal.ts +148 -0
  73. package/src/observation.ts +134 -0
  74. package/src/pass-registry.ts +43 -0
  75. package/src/pipeline/__tests__/color-value-domain.test.ts +43 -0
  76. package/src/pipeline/color-value-domain.ts +139 -0
  77. package/src/resource-registry.ts +173 -0
  78. package/src/types.ts +217 -0
@@ -0,0 +1,2549 @@
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
+ format: 'not-a-gpu-texture-format',
1319
+ size: { w: 1, h: 1 },
1320
+ usage: 0,
1321
+ });
1322
+ graph.addPass('candidate-pass', { reads: [], writes: ['candidate'] });
1323
+
1324
+ const refused = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
1325
+ expect(refused.ok).toBe(false);
1326
+ if (refused.ok) return;
1327
+ expect(refused.error.code).toBe('invalid-format');
1328
+ expect(refused.error.detail).toMatchObject({
1329
+ resourceKey: 'candidate',
1330
+ format: 'not-a-gpu-texture-format',
1331
+ });
1332
+ const detail = refused.error.detail as {
1333
+ readonly resourceKey: string;
1334
+ readonly format: string;
1335
+ readonly expected: readonly string[];
1336
+ };
1337
+ expect(detail.expected).toContain('rgba8unorm');
1338
+ expect(createTextureCalls).toBe(1);
1339
+ expect(createTextureViewCalls).toBe(1);
1340
+ expect(graph.getColorTargetTexture('healthy')).toBe(healthyTexture);
1341
+ expect(graph.getColorTargetView('healthy')).toBe(healthyView);
1342
+ expect(graph.getColorTargetTexture('candidate')).toBeUndefined();
1343
+
1344
+ const duplicateCandidate = graph.addColorTarget('candidate', {
1345
+ format: 'rgba8unorm',
1346
+ size: { w: 1, h: 1 },
1347
+ usage: 0,
1348
+ });
1349
+ expect(duplicateCandidate.ok).toBe(false);
1350
+ if (duplicateCandidate.ok) return;
1351
+ expect(duplicateCandidate.error.code).toBe('duplicate-resource');
1352
+ expect(duplicateCandidate.error.detail).toEqual({ resourceKey: 'candidate' });
1353
+ expect(createTextureCalls).toBe(1);
1354
+ expect(createTextureViewCalls).toBe(1);
1355
+ expect(graph.getColorTargetTexture('healthy')).toBe(healthyTexture);
1356
+ expect(graph.getColorTargetView('healthy')).toBe(healthyView);
1357
+ expect(graph.getColorTargetTexture('candidate')).toBeUndefined();
1358
+ });
1359
+ });
1360
+ }
1361
+
1362
+ {
1363
+ // --- feat-20260612 M4 / w13: RenderGraph.drain unit test ---
1364
+ //
1365
+ // Coverage matrix (plan-strategy D-7 + OOS-7):
1366
+ // 1. drain() walks transientPool and calls device.destroyTexture for each
1367
+ // pooled texture handle.
1368
+ // 2. drain() walks persistentTextures and calls device.destroyTexture for
1369
+ // each persistent texture handle.
1370
+ // 3. After drain(), both pools are empty (size === 0).
1371
+ // 4. drain() is idempotent: a second call after the pools were cleared
1372
+ // is a no-op (zero further destroyTexture invocations).
1373
+ //
1374
+ // Mock shape: a minimal RhiDevice stub with createTexture / createTextureView
1375
+ // / destroyTexture (the only surface the allocate + drain paths touch).
1376
+
1377
+ function drainMockCaps() {
1378
+ return {
1379
+ backendKind: 'webgpu' as const,
1380
+ compute: true,
1381
+ storageBuffer: true,
1382
+ storageTexture: false,
1383
+ timestampQuery: false,
1384
+ timestampPeriodNanoseconds: null,
1385
+ indirectDrawing: false,
1386
+ textureCompressionBc: false,
1387
+ textureCompressionEtc2: false,
1388
+ textureCompressionAstc: false,
1389
+ multiDrawIndirect: false,
1390
+ pushConstants: false,
1391
+ textureBindingArray: false,
1392
+ samplerAliasing: true,
1393
+ firstInstanceIndirect: false,
1394
+ rgba16floatRenderable: false,
1395
+ rg11b10ufloatRenderable: false,
1396
+ float32Filterable: false,
1397
+ maxColorAttachments: 8,
1398
+ };
1399
+ }
1400
+
1401
+ interface DrainProbe {
1402
+ created: number;
1403
+ destroyed: number;
1404
+ destroyedHandles: object[];
1405
+ }
1406
+
1407
+ function makeDrainDevice(probe: DrainProbe): {
1408
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1409
+ [k: string]: any;
1410
+ } {
1411
+ return {
1412
+ createTexture: (_desc: unknown) => {
1413
+ probe.created += 1;
1414
+ const handle = { __mock: `tex-${probe.created}` };
1415
+ return { ok: true as const, value: handle };
1416
+ },
1417
+ createTextureView: (_tex: unknown, _desc: unknown) => {
1418
+ return { ok: true as const, value: { __mock: 'view' } };
1419
+ },
1420
+ destroyTexture: (tex: object) => {
1421
+ probe.destroyed += 1;
1422
+ probe.destroyedHandles.push(tex);
1423
+ return { ok: true as const, value: undefined };
1424
+ },
1425
+ queue: {
1426
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
1427
+ },
1428
+ };
1429
+ }
1430
+
1431
+ describe('RenderGraph.drain (feat-20260612 M4 / w13)', () => {
1432
+ it('drain destroys every transient pool entry and clears the pool', () => {
1433
+ const probe: DrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1434
+ const g = new RenderGraph();
1435
+ g.addColorTarget('hdrColor', {
1436
+ format: 'rgba16float',
1437
+ size: { w: 64, h: 64 },
1438
+ sample: 1,
1439
+ usage: 0,
1440
+ });
1441
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
1442
+
1443
+ const r = g.compile({
1444
+ backendKind: 'webgpu',
1445
+ caps: drainMockCaps(),
1446
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1447
+ device: makeDrainDevice(probe) as any,
1448
+ });
1449
+ expect(r.ok).toBe(true);
1450
+ expect(probe.created).toBeGreaterThanOrEqual(1);
1451
+
1452
+ const baselineDestroyed = probe.destroyed;
1453
+ g.drain();
1454
+
1455
+ // Every pool entry got a destroyTexture call.
1456
+ expect(probe.destroyed - baselineDestroyed).toBe(probe.created);
1457
+ });
1458
+
1459
+ it('drain destroys every persistent texture entry and clears the map', () => {
1460
+ const probe: DrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1461
+ const g = new RenderGraph();
1462
+ g.addColorTarget('shadowDepth', {
1463
+ format: 'depth32float',
1464
+ size: { w: 64, h: 64 },
1465
+ lifetime: 'persistent',
1466
+ usage: 0,
1467
+ });
1468
+ g.addPass('shadow', { reads: [], writes: ['shadowDepth'] });
1469
+ const r = g.compile({
1470
+ backendKind: 'webgpu',
1471
+ caps: drainMockCaps(),
1472
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1473
+ device: makeDrainDevice(probe) as any,
1474
+ });
1475
+ expect(r.ok).toBe(true);
1476
+ expect(probe.created).toBe(1);
1477
+
1478
+ g.drain();
1479
+ expect(probe.destroyed).toBe(1);
1480
+ expect(() => g.drain()).not.toThrow();
1481
+ expect(probe.destroyed).toBe(1);
1482
+ });
1483
+
1484
+ it('drain is idempotent: a second drain after a cleared pool is a no-op', () => {
1485
+ const probe: DrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1486
+ const g = new RenderGraph();
1487
+ g.addColorTarget('hdrColor', {
1488
+ format: 'rgba16float',
1489
+ size: { w: 32, h: 32 },
1490
+ sample: 1,
1491
+ usage: 0,
1492
+ });
1493
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
1494
+
1495
+ const r = g.compile({
1496
+ backendKind: 'webgpu',
1497
+ caps: drainMockCaps(),
1498
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1499
+ device: makeDrainDevice(probe) as any,
1500
+ });
1501
+ expect(r.ok).toBe(true);
1502
+
1503
+ g.drain();
1504
+ const afterFirst = probe.destroyed;
1505
+
1506
+ // Second drain: pools already cleared, no further destroyTexture fires.
1507
+ expect(() => g.drain()).not.toThrow();
1508
+ expect(probe.destroyed).toBe(afterFirst);
1509
+ });
1510
+
1511
+ it('drain on a never-compiled graph is a safe no-op', () => {
1512
+ const g = new RenderGraph();
1513
+ expect(() => g.drain()).not.toThrow();
1514
+ });
1515
+ });
1516
+ }
1517
+
1518
+ {
1519
+ // --- from uniform-fallback.test.ts ---
1520
+ // ── Helpers ──────────────────────────────────────────────────────
1521
+
1522
+ function mockCaps(overrides: { storageBuffer?: boolean }) {
1523
+ return {
1524
+ backendKind: 'webgpu' as const,
1525
+ compute: true,
1526
+ storageBuffer: overrides.storageBuffer ?? true,
1527
+ storageTexture: false,
1528
+ timestampQuery: false,
1529
+ timestampPeriodNanoseconds: null,
1530
+ indirectDrawing: false,
1531
+ textureCompressionBc: false,
1532
+ textureCompressionEtc2: false,
1533
+ textureCompressionAstc: false,
1534
+ multiDrawIndirect: false,
1535
+ pushConstants: false,
1536
+ textureBindingArray: false,
1537
+ samplerAliasing: true,
1538
+ firstInstanceIndirect: false,
1539
+ rgba16floatRenderable: false,
1540
+ rg11b10ufloatRenderable: false,
1541
+ float32Filterable: false,
1542
+ maxColorAttachments: 8,
1543
+ };
1544
+ }
1545
+
1546
+ function resolvedTypeOf(
1547
+ graph: { resolvedBuffers: readonly { key: string; resolvedBufferType: string }[] },
1548
+ key: string,
1549
+ ): string | undefined {
1550
+ return graph.resolvedBuffers.find((b) => b.key === key)?.resolvedBufferType;
1551
+ }
1552
+
1553
+ // ── uniform fallback (AC-09 / D-6.1) ─────────────────────────────
1554
+ //
1555
+ // compile() resolves a `kind:'buffer'` resource declared with
1556
+ // `bufferRole='auto-storage-or-uniform'` to a concrete RHI binding type by
1557
+ // reading `caps.storageBuffer` -- mirroring the runtime pbr-pipeline cap
1558
+ // switch (research Finding 7), expressed in the RHI-pure graph layer. The two
1559
+ // branches MUST resolve to different types; if the resolution logic is
1560
+ // deleted, `resolvedBuffers` goes empty and these assertions fail.
1561
+
1562
+ describe('uniform fallback (AC-09 / D-6.1)', () => {
1563
+ it('resolves auto-storage-or-uniform to uniform when storageBuffer=false', () => {
1564
+ const g = new RenderGraph();
1565
+ g.addResource('lightBuf', {
1566
+ kind: 'buffer',
1567
+ lifetime: 'persistent',
1568
+ bufferRole: 'auto-storage-or-uniform',
1569
+ });
1570
+ g.addPass('mainPass', { reads: [], writes: ['lightBuf'] });
1571
+
1572
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: false }) });
1573
+
1574
+ expect(r.ok).toBe(true);
1575
+ if (r.ok) {
1576
+ expect(resolvedTypeOf(r.value, 'lightBuf')).toBe('uniform');
1577
+ }
1578
+ });
1579
+
1580
+ it('resolves auto-storage-or-uniform to read-only-storage when storageBuffer=true', () => {
1581
+ const g = new RenderGraph();
1582
+ g.addResource('lightBuf', {
1583
+ kind: 'buffer',
1584
+ lifetime: 'persistent',
1585
+ bufferRole: 'auto-storage-or-uniform',
1586
+ });
1587
+ g.addPass('mainPass', { reads: [], writes: ['lightBuf'] });
1588
+
1589
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: true }) });
1590
+
1591
+ expect(r.ok).toBe(true);
1592
+ if (r.ok) {
1593
+ expect(resolvedTypeOf(r.value, 'lightBuf')).toBe('read-only-storage');
1594
+ }
1595
+ });
1596
+
1597
+ it('the two branches resolve to different types (catches a removed cap switch)', () => {
1598
+ const build = () => {
1599
+ const g = new RenderGraph();
1600
+ g.addResource('lightBuf', {
1601
+ kind: 'buffer',
1602
+ lifetime: 'persistent',
1603
+ bufferRole: 'auto-storage-or-uniform',
1604
+ });
1605
+ g.addPass('mainPass', { reads: [], writes: ['lightBuf'] });
1606
+ return g;
1607
+ };
1608
+
1609
+ const noStorage = build().compile({
1610
+ backendKind: 'webgpu',
1611
+ caps: mockCaps({ storageBuffer: false }),
1612
+ });
1613
+ const withStorage = build().compile({
1614
+ backendKind: 'webgpu',
1615
+ caps: mockCaps({ storageBuffer: true }),
1616
+ });
1617
+
1618
+ expect(noStorage.ok && withStorage.ok).toBe(true);
1619
+ if (noStorage.ok && withStorage.ok) {
1620
+ const a = resolvedTypeOf(noStorage.value, 'lightBuf');
1621
+ const b = resolvedTypeOf(withStorage.value, 'lightBuf');
1622
+ expect(a).toBeDefined();
1623
+ expect(b).toBeDefined();
1624
+ expect(a).not.toBe(b);
1625
+ }
1626
+ });
1627
+
1628
+ it('bufferRole=uniform always resolves to uniform regardless of caps', () => {
1629
+ const build = () => {
1630
+ const g = new RenderGraph();
1631
+ g.addResource('camBuf', { kind: 'buffer', lifetime: 'persistent', bufferRole: 'uniform' });
1632
+ g.addPass('mainPass', { reads: [], writes: ['camBuf'] });
1633
+ return g;
1634
+ };
1635
+
1636
+ const withStorage = build().compile({
1637
+ backendKind: 'webgpu',
1638
+ caps: mockCaps({ storageBuffer: true }),
1639
+ });
1640
+ const noStorage = build().compile({
1641
+ backendKind: 'webgpu',
1642
+ caps: mockCaps({ storageBuffer: false }),
1643
+ });
1644
+
1645
+ expect(withStorage.ok && noStorage.ok).toBe(true);
1646
+ if (withStorage.ok && noStorage.ok) {
1647
+ expect(resolvedTypeOf(withStorage.value, 'camBuf')).toBe('uniform');
1648
+ expect(resolvedTypeOf(noStorage.value, 'camBuf')).toBe('uniform');
1649
+ }
1650
+ });
1651
+
1652
+ it('defaults an unset bufferRole to the auto cap switch', () => {
1653
+ const g = new RenderGraph();
1654
+ g.addResource('plainBuf', { kind: 'buffer', lifetime: 'persistent' });
1655
+ g.addPass('mainPass', { reads: [], writes: ['plainBuf'] });
1656
+
1657
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: false }) });
1658
+
1659
+ expect(r.ok).toBe(true);
1660
+ if (r.ok) {
1661
+ expect(resolvedTypeOf(r.value, 'plainBuf')).toBe('uniform');
1662
+ }
1663
+ });
1664
+
1665
+ it('omits texture resources from resolvedBuffers', () => {
1666
+ const g = new RenderGraph();
1667
+ g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
1668
+ g.addPass('mainPass', { reads: [], writes: ['hdrColor'] });
1669
+
1670
+ const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: true }) });
1671
+
1672
+ expect(r.ok).toBe(true);
1673
+ if (r.ok) {
1674
+ expect(r.value.resolvedBuffers).toEqual([]);
1675
+ }
1676
+ });
1677
+ });
1678
+ }
1679
+
1680
+ {
1681
+ // --- feat-20260619-gpu-resource-ownership-symmetric-release-primitive M3 / w9: AC-09 resize drain ---
1682
+ //
1683
+ // Transient pool key includes dimensions (name:format:WxH:usage:sample). When
1684
+ // swap-chain size changes, old-dimension entries become stranded because the
1685
+ // new compile creates entries with new-dimension keys and the old ones are
1686
+ // never accessed again. drainTransient() must release all transient pool
1687
+ // textures on resize before reallocation.
1688
+ //
1689
+ // Coverage:
1690
+ // 1. Resize triggers drain of old transient entries (destroyTexture called per old entry).
1691
+ // 2. After resize drain, old keys are gone from the pool; new keys for new
1692
+ // dimensions exist.
1693
+ // 3. Non-resize recompile does NOT drain (pool entries survive when dimensions unchanged).
1694
+ // 4. drainTransient only touches transientPool, not persistentTextures.
1695
+
1696
+ function resizeDrainMockCaps() {
1697
+ return {
1698
+ backendKind: 'webgpu' as const,
1699
+ compute: true,
1700
+ storageBuffer: true,
1701
+ storageTexture: false,
1702
+ timestampQuery: false,
1703
+ timestampPeriodNanoseconds: null,
1704
+ indirectDrawing: false,
1705
+ textureCompressionBc: false,
1706
+ textureCompressionEtc2: false,
1707
+ textureCompressionAstc: false,
1708
+ multiDrawIndirect: false,
1709
+ pushConstants: false,
1710
+ textureBindingArray: false,
1711
+ samplerAliasing: true,
1712
+ firstInstanceIndirect: false,
1713
+ rgba16floatRenderable: false,
1714
+ rg11b10ufloatRenderable: false,
1715
+ float32Filterable: false,
1716
+ maxColorAttachments: 8,
1717
+ };
1718
+ }
1719
+
1720
+ interface ResizeDrainProbe {
1721
+ created: number;
1722
+ destroyed: number;
1723
+ destroyedHandles: object[];
1724
+ }
1725
+
1726
+ function makeResizeDrainDevice(probe: ResizeDrainProbe): Record<string, unknown> {
1727
+ return {
1728
+ createTexture: (_desc: unknown) => {
1729
+ probe.created += 1;
1730
+ return { ok: true as const, value: { __mock: `tex-${probe.created}` } };
1731
+ },
1732
+ createTextureView: (_tex: unknown, _desc: unknown) => {
1733
+ return { ok: true as const, value: { __mock: 'view' } };
1734
+ },
1735
+ destroyTexture: (tex: object) => {
1736
+ probe.destroyed += 1;
1737
+ probe.destroyedHandles.push(tex);
1738
+ return { ok: true as const, value: undefined };
1739
+ },
1740
+ queue: {
1741
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
1742
+ },
1743
+ };
1744
+ }
1745
+
1746
+ describe('transient pool resize drain (AC-09)', () => {
1747
+ it('resize enqueues old transient entries to pendingDestroy and reclaim destroys them', async () => {
1748
+ const probe: ResizeDrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1749
+ const g = new RenderGraph();
1750
+ g.addColorTarget('hdrColor', {
1751
+ format: 'rgba16float',
1752
+ size: 'swapchain',
1753
+ sample: 1,
1754
+ usage: 0,
1755
+ });
1756
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
1757
+
1758
+ // Compile at 64x64.
1759
+ g.setSwapChainSize(64, 64);
1760
+ const r1 = g.compile({
1761
+ backendKind: 'webgpu',
1762
+ caps: resizeDrainMockCaps(),
1763
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1764
+ device: makeResizeDrainDevice(probe) as any,
1765
+ });
1766
+ expect(r1.ok).toBe(true);
1767
+ const createdAfterFirst = probe.created;
1768
+ expect(createdAfterFirst).toBeGreaterThanOrEqual(1);
1769
+ const destroyedBeforeResize = probe.destroyed;
1770
+
1771
+ // Resize to 128x128 triggers recompile signal.
1772
+ const resizeDetected = g.setSwapChainSize(128, 128);
1773
+ expect(resizeDetected).toBe(true);
1774
+
1775
+ const r2 = g.compile({
1776
+ backendKind: 'webgpu',
1777
+ caps: resizeDrainMockCaps(),
1778
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1779
+ device: makeResizeDrainDevice(probe) as any,
1780
+ });
1781
+ expect(r2.ok).toBe(true);
1782
+
1783
+ // bug-20260622 D-1: old textures pushed to pendingDestroy, not
1784
+ // destroyed immediately. Resize compile should NOT call destroyTexture.
1785
+ const destroyedAtResize = probe.destroyed - destroyedBeforeResize;
1786
+ expect(destroyedAtResize).toBe(0);
1787
+
1788
+ // New textures were created for the new size.
1789
+ expect(probe.created).toBe(createdAfterFirst * 2);
1790
+
1791
+ // After reclaim, old textures are destroyed.
1792
+ await g.reclaimRetiredTransients();
1793
+ expect(probe.destroyed - destroyedBeforeResize).toBe(createdAfterFirst);
1794
+ });
1795
+
1796
+ it('non-resize recompile leaves transient pool intact (no spurious drain)', () => {
1797
+ const probe: ResizeDrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1798
+ const g = new RenderGraph();
1799
+ g.addColorTarget('hdrColor', {
1800
+ format: 'rgba16float',
1801
+ size: 'swapchain',
1802
+ sample: 1,
1803
+ usage: 0,
1804
+ });
1805
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
1806
+
1807
+ // Compile at 64x64 — no prior size set, so setSwapChainSize(64,64) returns true.
1808
+ g.setSwapChainSize(64, 64);
1809
+ const r1 = g.compile({
1810
+ backendKind: 'webgpu',
1811
+ caps: resizeDrainMockCaps(),
1812
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1813
+ device: makeResizeDrainDevice(probe) as any,
1814
+ });
1815
+ expect(r1.ok).toBe(true);
1816
+
1817
+ const destroyedAfterFirst = probe.destroyed;
1818
+ const createdAfterFirst = probe.created;
1819
+
1820
+ // Recompile without resize — setSwapChainSize(64,64) returns false.
1821
+ const resizeDetected = g.setSwapChainSize(64, 64);
1822
+ expect(resizeDetected).toBe(false);
1823
+
1824
+ const r2 = g.compile({
1825
+ backendKind: 'webgpu',
1826
+ caps: resizeDrainMockCaps(),
1827
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1828
+ device: makeResizeDrainDevice(probe) as any,
1829
+ });
1830
+ expect(r2.ok).toBe(true);
1831
+
1832
+ // No drain on non-resize: destroyTexture count unchanged, pool hit reused
1833
+ // the existing texture (no new createTexture calls).
1834
+ expect(probe.destroyed).toBe(destroyedAfterFirst);
1835
+ expect(probe.created).toBe(createdAfterFirst);
1836
+ });
1837
+
1838
+ it('drainTransient only clears transientPool, not persistentTextures', () => {
1839
+ const probe: ResizeDrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1840
+ const g = new RenderGraph();
1841
+ g.addColorTarget('hdrColor', {
1842
+ format: 'rgba16float',
1843
+ size: 'swapchain',
1844
+ sample: 1,
1845
+ usage: 0,
1846
+ });
1847
+ g.addColorTarget('history', {
1848
+ format: 'rgba16float',
1849
+ size: { w: 32, h: 32 },
1850
+ lifetime: 'persistent',
1851
+ usage: 0,
1852
+ });
1853
+ g.addPass('main', { reads: [], writes: ['hdrColor', 'history'] });
1854
+
1855
+ g.setSwapChainSize(64, 64);
1856
+ const r1 = g.compile({
1857
+ backendKind: 'webgpu',
1858
+ caps: resizeDrainMockCaps(),
1859
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1860
+ device: makeResizeDrainDevice(probe) as any,
1861
+ });
1862
+ expect(r1.ok).toBe(true);
1863
+
1864
+ // Snapshot persistentTextures before resize.
1865
+ // biome-ignore lint/suspicious/noExplicitAny: internal pool access in unit test
1866
+ const ptexBefore = (g as any).persistentTextures as Map<string, unknown>;
1867
+ const ptexSizeBefore = ptexBefore.size;
1868
+ const historyBefore = g.getColorTargetTexture('history');
1869
+
1870
+ // Resize.
1871
+ g.setSwapChainSize(128, 128);
1872
+ const r2 = g.compile({
1873
+ backendKind: 'webgpu',
1874
+ caps: resizeDrainMockCaps(),
1875
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1876
+ device: makeResizeDrainDevice(probe) as any,
1877
+ });
1878
+ expect(r2.ok).toBe(true);
1879
+
1880
+ // Persistent textures untouched by drainTransient.
1881
+ expect(ptexBefore.size).toBe(ptexSizeBefore);
1882
+ expect(g.getColorTargetTexture('history')).toBe(historyBefore);
1883
+ expect(probe.created).toBe(3);
1884
+ });
1885
+ });
1886
+ }
1887
+
1888
+ {
1889
+ // --- bug-20260622-hdrcolor-transient-pool-destroy-in-flight M1: red baseline — deferred-destroy fence ---
1890
+ //
1891
+ // The existing AC-09 test asserts "resize triggers drainTransient which
1892
+ // destroys old textures immediately". This is the bug: those textures are
1893
+ // still referenced by the previous frame's in-flight command buffer.
1894
+ //
1895
+ // The fix adds a pendingDestroy queue (D-1) and a reclaimRetiredTransients
1896
+ // method (D-2) that defers actual destroyTexture until
1897
+ // device.queue.onSubmittedWorkDone() resolves.
1898
+ //
1899
+ // This test (M1 RED baseline) asserts the NEW expected behaviour:
1900
+ // 1. Resize compile pushes old textures into pendingDestroy, does NOT
1901
+ // call destroyTexture immediately.
1902
+ // 2. reclaimRetiredTransients() triggers onSubmittedWorkDone then
1903
+ // destroys the pending items.
1904
+ //
1905
+ // Currently RED — drainTransient destroys synchronously, so
1906
+ // destroyedAtResize > 0 (this test asserts destroyedAtResize === 0).
1907
+
1908
+ function deferredMockCaps() {
1909
+ return {
1910
+ backendKind: 'webgpu' as const,
1911
+ compute: true,
1912
+ storageBuffer: true,
1913
+ storageTexture: false,
1914
+ timestampQuery: false,
1915
+ timestampPeriodNanoseconds: null,
1916
+ indirectDrawing: false,
1917
+ textureCompressionBc: false,
1918
+ textureCompressionEtc2: false,
1919
+ textureCompressionAstc: false,
1920
+ multiDrawIndirect: false,
1921
+ pushConstants: false,
1922
+ textureBindingArray: false,
1923
+ samplerAliasing: true,
1924
+ firstInstanceIndirect: false,
1925
+ rgba16floatRenderable: false,
1926
+ rg11b10ufloatRenderable: false,
1927
+ float32Filterable: false,
1928
+ maxColorAttachments: 8,
1929
+ };
1930
+ }
1931
+
1932
+ interface DeferredProbe {
1933
+ created: number;
1934
+ destroyed: number;
1935
+ destroyedHandles: object[];
1936
+ }
1937
+
1938
+ function makeDeferredDevice(probe: DeferredProbe): Record<string, unknown> {
1939
+ return {
1940
+ createTexture: (_desc: unknown) => {
1941
+ probe.created += 1;
1942
+ return { ok: true as const, value: { __mock: `tex-${probe.created}` } };
1943
+ },
1944
+ createTextureView: (_tex: unknown, _desc: unknown) => {
1945
+ return { ok: true as const, value: { __mock: 'view' } };
1946
+ },
1947
+ destroyTexture: (tex: object) => {
1948
+ probe.destroyed += 1;
1949
+ probe.destroyedHandles.push(tex);
1950
+ return { ok: true as const, value: undefined };
1951
+ },
1952
+ queue: {
1953
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
1954
+ },
1955
+ };
1956
+ }
1957
+
1958
+ describe('deferred-destroy: resize enqueues pendingDestroy, not immediate destroy (AC-03 RED)', () => {
1959
+ it('resize compile pushes old transient textures into pendingDestroy (destroy count = 0 at resize)', () => {
1960
+ const probe: DeferredProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
1961
+ const g = new RenderGraph();
1962
+ g.addColorTarget('hdrColor', {
1963
+ format: 'rgba16float',
1964
+ size: 'swapchain',
1965
+ sample: 1,
1966
+ usage: 0,
1967
+ });
1968
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
1969
+
1970
+ // Compile at 64x64.
1971
+ g.setSwapChainSize(64, 64);
1972
+ const r1 = g.compile({
1973
+ backendKind: 'webgpu',
1974
+ caps: deferredMockCaps(),
1975
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1976
+ device: makeDeferredDevice(probe) as any,
1977
+ });
1978
+ expect(r1.ok).toBe(true);
1979
+ const createdAfterFirst = probe.created;
1980
+ expect(createdAfterFirst).toBeGreaterThanOrEqual(1);
1981
+
1982
+ const destroyedBeforeResize = probe.destroyed;
1983
+
1984
+ // Resize to 128x128.
1985
+ const resizeDetected = g.setSwapChainSize(128, 128);
1986
+ expect(resizeDetected).toBe(true);
1987
+
1988
+ const r2 = g.compile({
1989
+ backendKind: 'webgpu',
1990
+ caps: deferredMockCaps(),
1991
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
1992
+ device: makeDeferredDevice(probe) as any,
1993
+ });
1994
+ expect(r2.ok).toBe(true);
1995
+
1996
+ // AC-03 RED: after resize compile, old textures should NOT be destroyed
1997
+ // yet — they are in pendingDestroy queue, waiting for GPU retirement.
1998
+ // Currently RED because drainTransient destroys synchronously.
1999
+ const destroyedAtResize = probe.destroyed - destroyedBeforeResize;
2000
+ expect(destroyedAtResize).toBe(0);
2001
+ });
2002
+
2003
+ it('pendingDestroy is accessible as internal field after resize', () => {
2004
+ const probe: DeferredProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
2005
+ const g = new RenderGraph();
2006
+ g.addColorTarget('hdrColor', {
2007
+ format: 'rgba16float',
2008
+ size: 'swapchain',
2009
+ sample: 1,
2010
+ usage: 0,
2011
+ });
2012
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
2013
+
2014
+ g.setSwapChainSize(64, 64);
2015
+ g.compile({
2016
+ backendKind: 'webgpu',
2017
+ caps: deferredMockCaps(),
2018
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2019
+ device: makeDeferredDevice(probe) as any,
2020
+ });
2021
+
2022
+ g.setSwapChainSize(128, 128);
2023
+ g.compile({
2024
+ backendKind: 'webgpu',
2025
+ caps: deferredMockCaps(),
2026
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2027
+ device: makeDeferredDevice(probe) as any,
2028
+ });
2029
+
2030
+ // After resize, pendingDestroy should contain the old transient textures.
2031
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
2032
+ const pending = (g as any).pendingDestroy as unknown[];
2033
+ expect(Array.isArray(pending)).toBe(true);
2034
+ expect(pending.length).toBeGreaterThanOrEqual(1);
2035
+ });
2036
+
2037
+ it('reclaimRetiredTransients destroys pending items after onSubmittedWorkDone resolves', async () => {
2038
+ const probe: DeferredProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
2039
+ const g = new RenderGraph();
2040
+ g.addColorTarget('hdrColor', {
2041
+ format: 'rgba16float',
2042
+ size: 'swapchain',
2043
+ sample: 1,
2044
+ usage: 0,
2045
+ });
2046
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
2047
+
2048
+ g.setSwapChainSize(64, 64);
2049
+ g.compile({
2050
+ backendKind: 'webgpu',
2051
+ caps: deferredMockCaps(),
2052
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2053
+ device: makeDeferredDevice(probe) as any,
2054
+ });
2055
+
2056
+ const destroyedBeforeResize = probe.destroyed;
2057
+
2058
+ g.setSwapChainSize(128, 128);
2059
+ g.compile({
2060
+ backendKind: 'webgpu',
2061
+ caps: deferredMockCaps(),
2062
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2063
+ device: makeDeferredDevice(probe) as any,
2064
+ });
2065
+
2066
+ // At resize: no destroy yet.
2067
+ expect(probe.destroyed - destroyedBeforeResize).toBe(0);
2068
+
2069
+ // Reclaim: destroys pending items after queue.onSubmittedWorkDone resolves.
2070
+ // biome-ignore lint/suspicious/noExplicitAny: method not yet public
2071
+ await (g as any).reclaimRetiredTransients();
2072
+
2073
+ // After reclaim, all pending items should be destroyed.
2074
+ expect(probe.destroyed - destroyedBeforeResize).toBeGreaterThanOrEqual(1);
2075
+
2076
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
2077
+ const pending = (g as any).pendingDestroy as unknown[];
2078
+ expect(pending.length).toBe(0);
2079
+ });
2080
+ });
2081
+ }
2082
+
2083
+ {
2084
+ // --- feat-20260619-gpu-resource-ownership-symmetric-release-primitive M3 / w10: AC-08 same-key destroy ---
2085
+ //
2086
+ // Defensive: when transientPool.set(key, pooled) overwrites an existing key,
2087
+ // the old PooledTexture.texture must be destroyed first (symmetric release).
2088
+ // Currently this code path is unreachable because set() only follows a get()
2089
+ // miss; the test exercises the guard through a private helper setTransientEntry
2090
+ // that encapsulates the has-check + destroy + set logic (added in w11).
2091
+ //
2092
+ // The guard (w11, inside allocateColorTargets) is:
2093
+ // this.setTransientEntry(key, pooled)
2094
+ // where setTransientEntry does:
2095
+ // if (this.transientPool.has(key)) {
2096
+ // device.destroyTexture(this.transientPool.get(key)!.texture as Texture);
2097
+ // }
2098
+ // this.transientPool.set(key, pooled);
2099
+ //
2100
+ // This test directly calls setTransientEntry (internal, accessed via 'as any')
2101
+ // to verify the guard behavior. In RED phase (w10, no w11 yet), the method
2102
+ // does not exist — the test fails. In GREEN phase (w11), the method exists
2103
+ // and destroys the old texture before overwriting.
2104
+
2105
+ function sameKeyMockCaps() {
2106
+ return {
2107
+ backendKind: 'webgpu' as const,
2108
+ compute: true,
2109
+ storageBuffer: true,
2110
+ storageTexture: false,
2111
+ timestampQuery: false,
2112
+ timestampPeriodNanoseconds: null,
2113
+ indirectDrawing: false,
2114
+ textureCompressionBc: false,
2115
+ textureCompressionEtc2: false,
2116
+ textureCompressionAstc: false,
2117
+ multiDrawIndirect: false,
2118
+ pushConstants: false,
2119
+ textureBindingArray: false,
2120
+ samplerAliasing: true,
2121
+ firstInstanceIndirect: false,
2122
+ rgba16floatRenderable: false,
2123
+ rg11b10ufloatRenderable: false,
2124
+ float32Filterable: false,
2125
+ maxColorAttachments: 8,
2126
+ };
2127
+ }
2128
+
2129
+ describe('transient pool same-key destroy (AC-08)', () => {
2130
+ it('old pooled texture is enqueued to pendingDestroy on same-key set overwrite', () => {
2131
+ const destroyedHandles: object[] = [];
2132
+ const mockDevice = {
2133
+ createTexture: (_desc: unknown) => ({
2134
+ ok: true as const,
2135
+ value: { __mock: `tex-${Date.now()}` },
2136
+ }),
2137
+ createTextureView: (_tex: unknown, _desc: unknown) => ({
2138
+ ok: true as const,
2139
+ value: { __mock: 'view' },
2140
+ }),
2141
+ destroyTexture: (tex: object) => {
2142
+ destroyedHandles.push(tex);
2143
+ return { ok: true as const, value: undefined };
2144
+ },
2145
+ queue: {
2146
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
2147
+ },
2148
+ };
2149
+
2150
+ const g = new RenderGraph();
2151
+ g.addColorTarget('hdrColor', {
2152
+ format: 'rgba16float',
2153
+ size: { w: 64, h: 64 },
2154
+ sample: 1,
2155
+ usage: 0,
2156
+ });
2157
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
2158
+
2159
+ // Compile to set lastDevice and populate transientPool.
2160
+ const r = g.compile({
2161
+ backendKind: 'webgpu',
2162
+ caps: sameKeyMockCaps(),
2163
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2164
+ device: mockDevice as any,
2165
+ });
2166
+ expect(r.ok).toBe(true);
2167
+
2168
+ // Access internal transient pool to get an existing entry.
2169
+ // biome-ignore lint/suspicious/noExplicitAny: internal pool access in unit test
2170
+ const pool = (g as any).transientPool as Map<string, { texture: object; view: object }>;
2171
+ const entries = [...pool.entries()];
2172
+ expect(entries.length).toBeGreaterThanOrEqual(1);
2173
+ // biome-ignore lint/style/noNonNullAssertion: guarded by length check above
2174
+ const [key, oldEntry] = entries[0]!;
2175
+ const oldTexture = oldEntry.texture;
2176
+
2177
+ const destroyedBeforeSet = destroyedHandles.length;
2178
+
2179
+ // Call setTransientEntry to simulate same-key overwrite.
2180
+ // bug-20260622 D-1: old texture pushed to pendingDestroy, not
2181
+ // immediately destroyed.
2182
+ // biome-ignore lint/suspicious/noExplicitAny: internal method access in unit test
2183
+ (g as any).setTransientEntry(key, {
2184
+ texture: { __mock: 'replacement-tex' },
2185
+ view: { __mock: 'replacement-view' },
2186
+ });
2187
+
2188
+ // No immediate destroy — entry is now in pendingDestroy queue.
2189
+ expect(destroyedHandles.length).toBe(destroyedBeforeSet);
2190
+
2191
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
2192
+ const pending = (g as any).pendingDestroy as { texture: object; view: object }[];
2193
+ expect(pending.length).toBeGreaterThanOrEqual(1);
2194
+ expect(pending.some((p) => p.texture === oldTexture)).toBe(true);
2195
+ });
2196
+ });
2197
+ }
2198
+
2199
+ {
2200
+ // --- w15: B-2 recover() clears pendingDestroy (feat-20260622-s5 M3) ---
2201
+ //
2202
+ // The recover() rebuild path (createRenderer M3) tears down the old device
2203
+ // and requests a fresh one. The render-graph's pendingDestroy queue still
2204
+ // holds PooledTexture handles minted against the OLD device; calling
2205
+ // destroyTexture on them against the NEW device is meaningless (and may
2206
+ // throw). recover() therefore drops the queue via clearPendingDestroy()
2207
+ // instead of draining it (B-2 / B-AC-02, plan-strategy D-5). This mirrors
2208
+ // the existing null-device fast path (`pendingDestroy.length = 0`) but is a
2209
+ // public method recover() can call directly.
2210
+
2211
+ function clearPendingCaps() {
2212
+ return {
2213
+ backendKind: 'webgpu' as const,
2214
+ compute: true,
2215
+ storageBuffer: true,
2216
+ storageTexture: false,
2217
+ timestampQuery: false,
2218
+ timestampPeriodNanoseconds: null,
2219
+ indirectDrawing: false,
2220
+ textureCompressionBc: false,
2221
+ textureCompressionEtc2: false,
2222
+ textureCompressionAstc: false,
2223
+ multiDrawIndirect: false,
2224
+ pushConstants: false,
2225
+ textureBindingArray: false,
2226
+ samplerAliasing: true,
2227
+ firstInstanceIndirect: false,
2228
+ rgba16floatRenderable: false,
2229
+ rg11b10ufloatRenderable: false,
2230
+ float32Filterable: false,
2231
+ maxColorAttachments: 8,
2232
+ };
2233
+ }
2234
+
2235
+ // --- w20: B-1 destroyTexture error tolerance (feat-20260622-s5 M4) ---
2236
+ //
2237
+ // The render-graph's drain() and reclaimRetiredTransients() docstrings
2238
+ // claim per-handle destroy errors are tolerated (swallow-and-continue),
2239
+ // but the current implementation uses bare for-loops with no try/catch.
2240
+ // B-1 fixes the gap (plan-strategy D-4 / PD3): any single
2241
+ // device.destroyTexture failure must not interrupt the destroy chain
2242
+ // for subsequent handles. Test coverage (w20, B-AC-01):
2243
+ // 1. reclaimRetiredTransients snapshot loop — error on item 0,
2244
+ // items 1..N-1 still destroyed, queue empty after.
2245
+ // 2. drain() loops (transient / pendingDestroy / persistent) — same
2246
+ // per-handle tolerance policy via a shared mock device.
2247
+
2248
+ function b1ToleranceCaps() {
2249
+ return {
2250
+ backendKind: 'webgpu' as const,
2251
+ compute: true,
2252
+ storageBuffer: true,
2253
+ storageTexture: false,
2254
+ timestampQuery: false,
2255
+ timestampPeriodNanoseconds: null,
2256
+ indirectDrawing: false,
2257
+ textureCompressionBc: false,
2258
+ textureCompressionEtc2: false,
2259
+ textureCompressionAstc: false,
2260
+ multiDrawIndirect: false,
2261
+ pushConstants: false,
2262
+ textureBindingArray: false,
2263
+ samplerAliasing: true,
2264
+ firstInstanceIndirect: false,
2265
+ rgba16floatRenderable: false,
2266
+ rg11b10ufloatRenderable: false,
2267
+ float32Filterable: false,
2268
+ maxColorAttachments: 8,
2269
+ };
2270
+ }
2271
+
2272
+ interface B1Probe {
2273
+ created: number;
2274
+ destroyed: number;
2275
+ destroyedHandles: object[];
2276
+ /** If non-negative, destroyTexture throws on this 0-based invocation. */
2277
+ throwOnCall: number;
2278
+ destroyCallCount: number;
2279
+ }
2280
+
2281
+ function makeB1Device(probe: B1Probe): {
2282
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2283
+ [k: string]: any;
2284
+ } {
2285
+ return {
2286
+ createTexture: (_desc: unknown) => {
2287
+ probe.created += 1;
2288
+ const handle = { __mock: `tex-${probe.created}` };
2289
+ return { ok: true as const, value: handle };
2290
+ },
2291
+ createTextureView: (_tex: unknown, _desc: unknown) => {
2292
+ return { ok: true as const, value: { __mock: 'view' } };
2293
+ },
2294
+ destroyTexture: (tex: object) => {
2295
+ probe.destroyCallCount += 1;
2296
+ if (probe.destroyCallCount - 1 === probe.throwOnCall) {
2297
+ throw new Error('mock destroyTexture error (simulated)');
2298
+ }
2299
+ probe.destroyed += 1;
2300
+ probe.destroyedHandles.push(tex);
2301
+ return { ok: true as const, value: undefined };
2302
+ },
2303
+ queue: {
2304
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
2305
+ },
2306
+ };
2307
+ }
2308
+
2309
+ describe('B-1 destroyTexture error tolerance (w20)', () => {
2310
+ it('reclaimRetiredTransients: item-0 error does not stop item 1..N from being destroyed', async () => {
2311
+ const probe: B1Probe = {
2312
+ created: 0,
2313
+ destroyed: 0,
2314
+ destroyedHandles: [],
2315
+ throwOnCall: 0,
2316
+ destroyCallCount: 0,
2317
+ };
2318
+ const mockDevice = makeB1Device(probe);
2319
+ const g = new RenderGraph();
2320
+ g.addColorTarget('hdrColor', {
2321
+ format: 'rgba16float',
2322
+ size: { w: 64, h: 64 },
2323
+ sample: 1,
2324
+ usage: 0,
2325
+ });
2326
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
2327
+
2328
+ const r = g.compile({
2329
+ backendKind: 'webgpu',
2330
+ caps: b1ToleranceCaps(),
2331
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2332
+ device: mockDevice as any,
2333
+ });
2334
+ expect(r.ok).toBe(true);
2335
+
2336
+ // Populate pendingDestroy with >= 3 entries by replacing the
2337
+ // transient pool entry three times (setTransientEntry pushes the
2338
+ // old one to pendingDestroy each time).
2339
+ // biome-ignore lint/suspicious/noExplicitAny: internal pool + pending access in unit test
2340
+ const pool = (g as any).transientPool as Map<string, { texture: object; view: object }>;
2341
+ for (let i = 0; i < 3; i++) {
2342
+ const [key] = [...pool.entries()][0] as [string, { texture: object }];
2343
+ // biome-ignore lint/suspicious/noExplicitAny: internal method access
2344
+ (g as any).setTransientEntry(key, {
2345
+ texture: { __mock: `entry-${i}` },
2346
+ view: { __mock: 'view' },
2347
+ });
2348
+ }
2349
+
2350
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access
2351
+ const pending = (g as any).pendingDestroy as { texture: object; view: object }[];
2352
+ expect(pending.length).toBe(3);
2353
+
2354
+ // reclaimRetiredTransients takes a snapshot and calls
2355
+ // device.destroyTexture on each. Item 0 throws; items 1,2 must
2356
+ // still be destroyed (swallow-and-continue). The queue must be
2357
+ // empty afterwards.
2358
+ await expect(g.reclaimRetiredTransients()).resolves.toBeUndefined();
2359
+
2360
+ // After reclaim: queue drained (items consumed from the snapshot;
2361
+ // snapshot was spliced before the loop, so pendingDestroy is empty
2362
+ // because the loop consumed the snapshot array in-place).
2363
+ expect(pending.length).toBe(0);
2364
+
2365
+ // Item 0 threw, but items 1,2 succeeded -> at least 2 destroyed.
2366
+ expect(probe.destroyed).toBe(2);
2367
+ // destroyTexture was called 3 times (item 0 threw, items 1,2 succeeded).
2368
+ expect(probe.destroyCallCount).toBe(3);
2369
+ });
2370
+
2371
+ it('drain: transient-pool destroy error on item 0 does not stop subsequent items from being destroyed', () => {
2372
+ const probe: B1Probe = {
2373
+ created: 0,
2374
+ destroyed: 0,
2375
+ destroyedHandles: [],
2376
+ throwOnCall: 0,
2377
+ destroyCallCount: 0,
2378
+ };
2379
+ const mockDevice = makeB1Device(probe);
2380
+ const g = new RenderGraph();
2381
+ g.addColorTarget('a', {
2382
+ format: 'rgba16float',
2383
+ size: { w: 32, h: 32 },
2384
+ sample: 1,
2385
+ usage: 0,
2386
+ });
2387
+ g.addColorTarget('b', {
2388
+ format: 'rgba8unorm',
2389
+ size: { w: 32, h: 32 },
2390
+ sample: 1,
2391
+ usage: 0,
2392
+ });
2393
+ g.addPass('main', { reads: [], writes: ['a', 'b'] });
2394
+
2395
+ const r = g.compile({
2396
+ backendKind: 'webgpu',
2397
+ caps: b1ToleranceCaps(),
2398
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2399
+ device: mockDevice as any,
2400
+ });
2401
+ expect(r.ok).toBe(true);
2402
+
2403
+ // Both color targets created transient pool entries.
2404
+ // drain() walks transientPool.values(), then pendingDestroy, then
2405
+ // persistentTextures. With throwOnCall=0, the very first
2406
+ // destroyTexture (for the first transient pool entry) throws.
2407
+ // Subsequent entries must still be destroyed.
2408
+ //
2409
+ // We can't use compile+resize to enqueue pendingDestroy because
2410
+ // the recompile with the same device would use the same pool.
2411
+ // Instead we exercise the transient pool destroy path directly
2412
+ // via drain(), which walks all 3 loops. The transient pool has 2
2413
+ // entries (a,b). Item 0 throws, item 1 succeeds.
2414
+ const destroyedBefore = probe.destroyed;
2415
+ g.drain();
2416
+
2417
+ // Item 1 was still destroyed after item 0 threw.
2418
+ // Transient pool has 2 entries -> at least 1 destroyed (item 0
2419
+ // threw, item 1 succeeded). pendingDestroy and persistent were
2420
+ // empty/cleared so no further calls.
2421
+ expect(probe.destroyed - destroyedBefore).toBe(1);
2422
+ expect(probe.destroyCallCount - destroyedBefore).toBe(2);
2423
+ });
2424
+
2425
+ it('drain: pendingDestroy teardown error on item 0 does not stop subsequent items', () => {
2426
+ const probe: B1Probe = {
2427
+ created: 0,
2428
+ destroyed: 0,
2429
+ destroyedHandles: [],
2430
+ throwOnCall: 2, // throw on the 3rd destroy call (0-based)
2431
+ // transient pool has 2 entries -> calls 0,1
2432
+ // pendingDestroy has 3 entries -> calls 2,3,4
2433
+ // throwOnCall=2 -> error on pendingDestroy item 0
2434
+ destroyCallCount: 0,
2435
+ };
2436
+ const mockDevice = makeB1Device(probe);
2437
+ const g = new RenderGraph();
2438
+ g.addColorTarget('a', {
2439
+ format: 'rgba16float',
2440
+ size: { w: 32, h: 32 },
2441
+ sample: 1,
2442
+ usage: 0,
2443
+ });
2444
+ g.addPass('main', { reads: [], writes: ['a'] });
2445
+
2446
+ const r = g.compile({
2447
+ backendKind: 'webgpu',
2448
+ caps: b1ToleranceCaps(),
2449
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2450
+ device: mockDevice as any,
2451
+ });
2452
+ expect(r.ok).toBe(true);
2453
+
2454
+ // Push 3 entries into pendingDestroy.
2455
+ for (let i = 0; i < 3; i++) {
2456
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access
2457
+ (g as any).pendingDestroy.push({ texture: { __mock: `pd-${i}` }, view: { __mock: 'v' } });
2458
+ }
2459
+
2460
+ const destroyedBefore = probe.destroyed;
2461
+ g.drain();
2462
+
2463
+ // transient pool (1 entry) -> calls 0 (ok)
2464
+ // pendingDestroy (3 entries) -> call 1 throws (item 0 of pendingDestroy),
2465
+ // calls 2,3 succeed (items 1,2 of pendingDestroy)
2466
+ // persistent -> empty, nothing
2467
+ // Total: call 0 ok + call 1 throw + call 2 ok + call 3 ok = 3 successes
2468
+ expect(probe.destroyed - destroyedBefore).toBe(3);
2469
+ // 4 total calls attempted
2470
+ expect(probe.destroyCallCount - destroyedBefore).toBe(4);
2471
+ // pendingDestroy cleared
2472
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access
2473
+ expect(((g as any).pendingDestroy as unknown[]).length).toBe(0);
2474
+ });
2475
+ });
2476
+
2477
+ describe('RenderGraph.clearPendingDestroy (B-2 / B-AC-02)', () => {
2478
+ it('clears the pendingDestroy queue without calling destroyTexture on stale handles', () => {
2479
+ const destroyedHandles: object[] = [];
2480
+ const mockDevice = {
2481
+ createTexture: (_desc: unknown) => ({
2482
+ ok: true as const,
2483
+ value: { __mock: `tex-${Date.now()}-${Math.random()}` },
2484
+ }),
2485
+ createTextureView: (_tex: unknown, _desc: unknown) => ({
2486
+ ok: true as const,
2487
+ value: { __mock: 'view' },
2488
+ }),
2489
+ destroyTexture: (tex: object) => {
2490
+ destroyedHandles.push(tex);
2491
+ return { ok: true as const, value: undefined };
2492
+ },
2493
+ queue: {
2494
+ onSubmittedWorkDone: () => Promise.resolve(undefined),
2495
+ },
2496
+ };
2497
+
2498
+ const g = new RenderGraph();
2499
+ g.addColorTarget('hdrColor', {
2500
+ format: 'rgba16float',
2501
+ size: { w: 64, h: 64 },
2502
+ sample: 1,
2503
+ usage: 0,
2504
+ });
2505
+ g.addPass('main', { reads: [], writes: ['hdrColor'] });
2506
+
2507
+ const r = g.compile({
2508
+ backendKind: 'webgpu',
2509
+ caps: clearPendingCaps(),
2510
+ // biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
2511
+ device: mockDevice as any,
2512
+ });
2513
+ expect(r.ok).toBe(true);
2514
+
2515
+ // Populate pendingDestroy with a stale entry (simulating a resize-time
2516
+ // transient retirement that has not yet been reclaimed).
2517
+ // biome-ignore lint/suspicious/noExplicitAny: internal pool access in unit test
2518
+ const pool = (g as any).transientPool as Map<string, { texture: object; view: object }>;
2519
+ const [key, oldEntry] = [...pool.entries()][0] as [string, { texture: object; view: object }];
2520
+ // biome-ignore lint/suspicious/noExplicitAny: internal method access in unit test
2521
+ (g as any).setTransientEntry(key, {
2522
+ texture: { __mock: 'replacement-tex' },
2523
+ view: { __mock: 'replacement-view' },
2524
+ });
2525
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
2526
+ const pending = (g as any).pendingDestroy as { texture: object; view: object }[];
2527
+ expect(pending.length).toBeGreaterThanOrEqual(1);
2528
+ expect(pending.some((p) => p.texture === oldEntry.texture)).toBe(true);
2529
+
2530
+ const destroyedBefore = destroyedHandles.length;
2531
+
2532
+ // B-2: clearPendingDestroy empties the queue WITHOUT calling
2533
+ // destroyTexture (stale handles belong to the lost device).
2534
+ (g as { clearPendingDestroy: () => void }).clearPendingDestroy();
2535
+
2536
+ expect(pending.length).toBe(0);
2537
+ // No destroyTexture call on the stale device handles.
2538
+ expect(destroyedHandles.length).toBe(destroyedBefore);
2539
+ });
2540
+
2541
+ it('clearPendingDestroy on an empty queue is a safe no-op', () => {
2542
+ const g = new RenderGraph();
2543
+ expect(() => (g as { clearPendingDestroy: () => void }).clearPendingDestroy()).not.toThrow();
2544
+ // biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
2545
+ const pending = (g as any).pendingDestroy as unknown[];
2546
+ expect(pending.length).toBe(0);
2547
+ });
2548
+ });
2549
+ }