@forgeax/engine-runtime 0.1.20 → 0.1.23

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 (86) hide show
  1. package/README.md +105 -3
  2. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
  5. package/dist/__tests__/helpers/register-scene-components.d.ts.map +1 -1
  6. package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
  7. package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
  8. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  9. package/dist/createRenderer.d.ts.map +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.mjs +4 -2
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/sprite-param-values.d.ts +1 -1
  14. package/package.json +30 -30
  15. package/src/__tests__/active-camera.unit.test.ts +6 -8
  16. package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
  17. package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
  18. package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
  19. package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
  20. package/src/__tests__/asset.unit.test.ts +5 -2
  21. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  22. package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
  23. package/src/__tests__/components.test-d.ts +19 -9
  24. package/src/__tests__/components.unit.test.ts +36 -26
  25. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  26. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  27. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  28. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
  29. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  30. package/src/__tests__/errors.unit.test.ts +54 -20
  31. package/src/__tests__/extract-frames-merge.test.ts +51 -40
  32. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  33. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  34. package/src/__tests__/geometry.unit.test.ts +7 -10
  35. package/src/__tests__/helpers/register-runtime-components.ts +9 -1
  36. package/src/__tests__/helpers/register-scene-components.ts +9 -2
  37. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +10 -11
  38. package/src/__tests__/light-snapshot.test-d.ts +35 -1
  39. package/src/__tests__/lights-preservation.fixup.test.ts +125 -0
  40. package/src/__tests__/lights.unit.test.ts +223 -308
  41. package/src/__tests__/loader-name-survival.test.ts +8 -9
  42. package/src/__tests__/package-promotion.test.ts +7 -11
  43. package/src/__tests__/pbr-pipeline.unit.test.ts +22 -17
  44. package/src/__tests__/pipeline.unit.test.ts +68 -226
  45. package/src/__tests__/point-light-shadow.browser.test.ts +209 -83
  46. package/src/__tests__/point-light-shadow.unit.test.ts +24 -47
  47. package/src/__tests__/propagate-transforms.dawn.test.ts +15 -16
  48. package/src/__tests__/record-worldid-isolation.test.ts +16 -32
  49. package/src/__tests__/render-error-exhaustive.test-d.ts +73 -9
  50. package/src/__tests__/render-system-extract.test.ts +5 -5
  51. package/src/__tests__/render-system-mega.test.ts +30 -41
  52. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +5 -1
  53. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -1
  54. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  55. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  56. package/src/__tests__/renderer-surface.unit.test.ts +229 -5
  57. package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
  58. package/src/__tests__/resolveName.test.ts +14 -2
  59. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  60. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
  61. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  62. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  63. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  64. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  65. package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
  66. package/src/__tests__/shadow-csm-ubo.test.ts +43 -45
  67. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  68. package/src/__tests__/shadow-fields-observable.dawn.test.ts +100 -24
  69. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +1 -1
  70. package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
  71. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +4 -7
  72. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  73. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
  74. package/src/__tests__/ssao-bgl.test.ts +12 -17
  75. package/src/__tests__/ssao-passes.test.ts +19 -2
  76. package/src/__tests__/systems.unit.test.ts +69 -205
  77. package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
  78. package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
  79. package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
  80. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  81. package/src/collect-scene-asset.ts +1 -1
  82. package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
  83. package/src/createRenderer.ts +2 -0
  84. package/src/index.ts +1 -2
  85. package/src/sprite-param-values.ts +1 -1
  86. package/dist/.tsbuildinfo +0 -1
@@ -164,7 +164,7 @@ function makeLights(): MockObj {
164
164
  shadowMapSize: 0,
165
165
  depthBias: 0,
166
166
  normalBias: 0,
167
- pcfKernelSize: 0,
167
+ directionalShadowQuality: undefined,
168
168
  pointShadow: [],
169
169
  };
170
170
  }
@@ -174,13 +174,12 @@ function makeFrameState(): MockObj {
174
174
  frameNumber: 1,
175
175
  compiledFrameGraph: null,
176
176
  compiledFrameGraphTopologyKey: null,
177
+ compiledFrameGraphGeneration: 0,
177
178
  retiredCompiledFrameGraphs: new Set(),
178
179
  instanceBuffers: new Map(),
179
180
  transientInstanceBuffers: [],
180
181
  warnedZeroLightStandard: false,
181
182
  warnedMultiLightDirectional: false,
182
- warnedMultiLightPoint: false,
183
- warnedMultiLightSpot: false,
184
183
  warnedSkyboxTonemapNone: false,
185
184
  warnedMissingBaseColorTextureHandles: new Set<number>(),
186
185
  warnedNineSliceScaleEntities: new Set<number>(),
@@ -193,8 +192,7 @@ function makeFrameState(): MockObj {
193
192
  installedPipelineHandle: 0,
194
193
  activePipeline: { buildGraph: () => null, execute: () => undefined },
195
194
  installedPipelineConfig: undefined,
196
- isHdrpActive: false,
197
- hdrpOncePerFrameFired: new Set(),
195
+ standardOncePerFrameFired: new Set(),
198
196
  pointShadowAtlas: null,
199
197
  pointShadowSnapshots: [],
200
198
  lastFoldBucketCount: 0,
@@ -231,6 +229,232 @@ function callRecordFrame(
231
229
  acquireSwapChainTarget(internals, internals.getPipelineState());
232
230
  }
233
231
 
232
+ type DirectionalShadowAdmission = {
233
+ readonly requested: 'off' | 'pcf1' | 'pcf3' | 'pcf5' | 'pcssMedium' | 'pcssHigh';
234
+ readonly effective:
235
+ | 'off'
236
+ | 'pcf1'
237
+ | 'pcf3'
238
+ | 'pcf5'
239
+ | 'pcssMedium'
240
+ | 'pcssHigh'
241
+ | 'rhi-null-structural';
242
+ readonly status: 'accepted' | 'fallback' | 'rejected';
243
+ readonly fallbackReason?: 'webgl2-unsupported' | 'rhi-null-structural' | 'candidate-failed';
244
+ readonly lastKnownGood: boolean;
245
+ readonly pixelEvidence: 'available' | 'not-available';
246
+ };
247
+
248
+ async function resolveDirectionalShadowAdmission(input: {
249
+ readonly backendKind: 'webgpu' | 'wgpu-webgl2' | 'null';
250
+ readonly requested: DirectionalShadowAdmission['requested'];
251
+ readonly candidate: 'accepted' | 'failed';
252
+ readonly lastKnownGood?: Exclude<
253
+ DirectionalShadowAdmission['effective'],
254
+ 'off' | 'rhi-null-structural'
255
+ >;
256
+ }): Promise<DirectionalShadowAdmission> {
257
+ const module = (await import('../../../render/src/render-pipeline')) as Record<string, unknown>;
258
+ const resolver = module.resolveDirectionalShadowBackendAdmission;
259
+ expect(typeof resolver).toBe('function');
260
+ return (resolver as (value: typeof input) => DirectionalShadowAdmission)(input);
261
+ }
262
+
263
+ describe('M3 backend admission and fallback truthfulness', () => {
264
+ it('keeps an explicitly disabled Directional shadow off without fabricating a candidate', async () => {
265
+ await expect(
266
+ resolveDirectionalShadowAdmission({
267
+ backendKind: 'webgpu',
268
+ requested: 'off',
269
+ candidate: 'failed',
270
+ }),
271
+ ).resolves.toMatchObject({
272
+ requested: 'off',
273
+ effective: 'off',
274
+ status: 'accepted',
275
+ lastKnownGood: false,
276
+ pixelEvidence: 'not-available',
277
+ });
278
+ });
279
+
280
+ it('admits a production WebGPU/Dawn candidate without converting PCSS to PCF', async () => {
281
+ await expect(
282
+ resolveDirectionalShadowAdmission({
283
+ backendKind: 'webgpu',
284
+ requested: 'pcssMedium',
285
+ candidate: 'accepted',
286
+ }),
287
+ ).resolves.toMatchObject({
288
+ requested: 'pcssMedium',
289
+ effective: 'pcssMedium',
290
+ status: 'accepted',
291
+ lastKnownGood: false,
292
+ pixelEvidence: 'available',
293
+ });
294
+ });
295
+
296
+ it.each([
297
+ ['pcssMedium', 'pcf3'],
298
+ ['pcssHigh', 'pcf5'],
299
+ ] as const)('maps WebGL2 %s to fixed effective %s', async (requested, effective) => {
300
+ await expect(
301
+ resolveDirectionalShadowAdmission({
302
+ backendKind: 'wgpu-webgl2',
303
+ requested,
304
+ candidate: 'accepted',
305
+ }),
306
+ ).resolves.toMatchObject({
307
+ requested,
308
+ effective,
309
+ status: 'fallback',
310
+ fallbackReason: 'webgl2-unsupported',
311
+ pixelEvidence: 'available',
312
+ });
313
+ });
314
+
315
+ it('projects RhiNull as structural evidence and never as pixel evidence', async () => {
316
+ await expect(
317
+ resolveDirectionalShadowAdmission({
318
+ backendKind: 'null',
319
+ requested: 'pcssHigh',
320
+ candidate: 'accepted',
321
+ }),
322
+ ).resolves.toMatchObject({
323
+ requested: 'pcssHigh',
324
+ effective: 'rhi-null-structural',
325
+ status: 'fallback',
326
+ fallbackReason: 'rhi-null-structural',
327
+ pixelEvidence: 'not-available',
328
+ });
329
+ });
330
+
331
+ it('rejects a capable candidate failure while retaining compatible LKG', async () => {
332
+ const result = await resolveDirectionalShadowAdmission({
333
+ backendKind: 'webgpu',
334
+ requested: 'pcssHigh',
335
+ candidate: 'failed',
336
+ lastKnownGood: 'pcf3',
337
+ });
338
+ expect(result).toMatchObject({
339
+ requested: 'pcssHigh',
340
+ effective: 'pcf3',
341
+ status: 'rejected',
342
+ fallbackReason: 'candidate-failed',
343
+ lastKnownGood: true,
344
+ pixelEvidence: 'available',
345
+ });
346
+ expect(result.effective).not.toBe('pcf5');
347
+ });
348
+
349
+ it('keeps falsifiers observable instead of allowing fake effective mappings', async () => {
350
+ const result = await resolveDirectionalShadowAdmission({
351
+ backendKind: 'wgpu-webgl2',
352
+ requested: 'pcssMedium',
353
+ candidate: 'accepted',
354
+ });
355
+ expect(result.fallbackReason).toBe('webgl2-unsupported');
356
+ expect(result.effective).toBe('pcf3');
357
+ expect(result).not.toMatchObject({ effective: 'pcssMedium' });
358
+ });
359
+ });
360
+
361
+ describe('M3 bounded Directional shadow inspection', () => {
362
+ it('requires one detached projection with bounded tap and generation facts', async () => {
363
+ const module = (await import(
364
+ '../../../render/src/assembly/directional-shadow-inspection'
365
+ )) as Record<string, unknown>;
366
+ const project = module.projectDirectionalShadowInspection;
367
+ expect(typeof project).toBe('function');
368
+ const result = (project as (input: Record<string, unknown>) => Record<string, unknown>)({
369
+ admission: {
370
+ requested: 'pcssHigh',
371
+ effective: 'pcssHigh',
372
+ status: 'accepted',
373
+ pixelEvidence: 'available',
374
+ },
375
+ cascadeCount: 4,
376
+ mapSize: 2048,
377
+ atlasBytes: 67_108_864,
378
+ writerPasses: 4,
379
+ blockerTaps: 16,
380
+ filterTapUpperBound: 32,
381
+ seamTapUpperBound: 96,
382
+ deviceGeneration: 2,
383
+ graphGeneration: 7,
384
+ });
385
+ expect(result).toMatchObject({
386
+ requested: 'pcssHigh',
387
+ effective: 'pcssHigh',
388
+ cascadeCount: 4,
389
+ mapSize: 2048,
390
+ blockerTaps: 16,
391
+ filterTapUpperBound: 32,
392
+ seamTapUpperBound: 96,
393
+ deviceGeneration: 2,
394
+ graphGeneration: 7,
395
+ });
396
+ expect(Object.keys(result)).not.toContain('perPixel');
397
+ expect(Object.isFrozen(result)).toBe(true);
398
+ });
399
+
400
+ it('projects production facts instead of fixed request, taps, or generations', async () => {
401
+ const module = (await import(
402
+ '../../../render/src/assembly/directional-shadow-inspection'
403
+ )) as Record<string, unknown>;
404
+ const projectSource = module.projectDirectionalShadowInspectionSource;
405
+ expect(typeof projectSource).toBe('function');
406
+ const project = projectSource as (input: Record<string, unknown>) => Record<string, unknown>;
407
+ const first = project({
408
+ requested: 'pcf3',
409
+ candidate: 'accepted',
410
+ cascadeCount: 3,
411
+ mapSize: 1024,
412
+ atlasBytes: 12_582_912,
413
+ writerPasses: 3,
414
+ deviceGeneration: 4,
415
+ graphGeneration: 11,
416
+ shadowReady: true,
417
+ });
418
+ const second = project({
419
+ requested: 'pcssHigh',
420
+ candidate: 'failed',
421
+ lastKnownGood: 'pcf3',
422
+ cascadeCount: 4,
423
+ mapSize: 2048,
424
+ atlasBytes: 67_108_864,
425
+ writerPasses: 4,
426
+ deviceGeneration: 5,
427
+ graphGeneration: 12,
428
+ shadowReady: false,
429
+ });
430
+ expect(first).toMatchObject({
431
+ requested: 'pcf3',
432
+ effective: 'pcf3',
433
+ status: 'accepted',
434
+ filterTapUpperBound: 9,
435
+ seamTapUpperBound: 9,
436
+ cascadeCount: 3,
437
+ mapSize: 1024,
438
+ deviceGeneration: 4,
439
+ graphGeneration: 11,
440
+ });
441
+ expect(second).toMatchObject({
442
+ requested: 'pcssHigh',
443
+ effective: 'pcf3',
444
+ status: 'rejected',
445
+ lastKnownGood: true,
446
+ blockerTaps: 16,
447
+ filterTapUpperBound: 32,
448
+ seamTapUpperBound: 96,
449
+ cascadeCount: 4,
450
+ mapSize: 2048,
451
+ deviceGeneration: 5,
452
+ graphGeneration: 12,
453
+ });
454
+ expect(second).not.toMatchObject({ requested: 'pcf3', status: 'accepted', atlasBytes: 0 });
455
+ });
456
+ });
457
+
234
458
  // ── w5: surface retry — reconfigure + retry once (AC-03) ─────────────────────
235
459
 
236
460
  describe('Surface retry (w5)', () => {
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // resolveName is the one runtime name-truth function and it delegates to the
4
4
  // stateless deriveAssetName pure function (D-6). Every other name consumer reads
5
- // resolveName or that same deriveAssetName -- none re-implements the XOR rule.
5
+ // resolveName or that same deriveAssetName -- none re-implements the display-name rule.
6
6
  //
7
7
  // This is a grep/fixture test, not a pure runtime assertion: it reads the source
8
8
  // trees and asserts structural unification.
@@ -27,11 +27,11 @@ const buildCatalogSrc = readFileSync(
27
27
  );
28
28
 
29
29
  describe('unified name resolution (AC-04)', () => {
30
- it('deriveAssetName is the single XOR rule definition', () => {
30
+ it('deriveAssetName is the single display-name rule definition', () => {
31
31
  expect(deriveSrc).toContain('export function deriveAssetName');
32
32
  });
33
33
 
34
- it('resolveName delegates to deriveAssetName (no inline XOR re-implementation)', () => {
34
+ it('resolveName delegates to deriveAssetName (no inline re-implementation)', () => {
35
35
  expect(registrySrc).toContain("import { deriveAssetName } from '@forgeax/engine-pack/name'");
36
36
  // resolveName returns the result of deriveAssetName, not a hand-rolled rule.
37
37
  const body = registrySrc.slice(
@@ -55,11 +55,11 @@ describe('unified name resolution (AC-04)', () => {
55
55
  });
56
56
 
57
57
  // M4 expansion (w22 landed): catalog-builder routes through deriveAssetName
58
- it('catalog-builder imports and calls deriveAssetName (no inline XOR)', () => {
58
+ it('catalog-builder imports and calls deriveAssetName (no inline display-name rule)', () => {
59
59
  expect(buildCatalogSrc).toContain("import { deriveAssetName } from './deriveAssetName.js'");
60
60
  expect(buildCatalogSrc).toContain('deriveAssetName(');
61
- // No inline re-implementation of the XOR rule (no hand-rolled basename)
62
- // The single deriveAssetName pure function handles all branches.
61
+ // No inline re-implementation (no hand-rolled basename); the single
62
+ // deriveAssetName pure function handles the display-name rule.
63
63
  expect(buildCatalogSrc.match(/deriveAssetName\(/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
64
64
  });
65
65
  });
@@ -4,8 +4,8 @@
4
4
  // state directly through registerPackage (Risk-2: no pack-index fetch needed).
5
5
  //
6
6
  // Coverage:
7
- // AC-01 — single-asset package: resolveName === basename(path); the stored
8
- // Asset POD carries no `name` property (name is never on the payload).
7
+ // AC-01 — single-asset package: an explicit stored name wins; basename(path)
8
+ // remains the fallback. The Asset POD carries no `name` property.
9
9
  // AC-02 — multi-asset package: resolveName returns each entry's stored name.
10
10
 
11
11
  import { AssetRegistry } from '@forgeax/engine-assets-runtime';
@@ -42,6 +42,18 @@ describe('resolveName single-asset package (AC-01)', () => {
42
42
  expect(stored).toBeDefined();
43
43
  expect(Object.hasOwn(stored as object, 'name')).toBe(false);
44
44
  });
45
+
46
+ it('preserves an explicit authored name for a single-member pack', () => {
47
+ const reg = makeRegistry();
48
+ reg.catalog(parseGuid(SINGLE_GUID), sampler);
49
+ reg._registerPackage(
50
+ 'assets/Materials.pack.json',
51
+ [SINGLE_GUID],
52
+ new Map([[SINGLE_GUID, 'NewMaterial']]),
53
+ );
54
+
55
+ expect(reg.resolveName(SINGLE_GUID)).toBe('NewMaterial');
56
+ });
45
57
  });
46
58
 
47
59
  describe('resolveName multi-asset package (AC-02)', () => {
@@ -6,6 +6,8 @@
6
6
  // cascade tile: col = i % tilesPerSide, row = floor(i / tilesPerSide).
7
7
 
8
8
  import { describe, expect, it } from 'vitest';
9
+ import { projectDirectionalShadowInspection } from '../../../render/src/assembly/directional-shadow-inspection';
10
+ import { resolveDirectionalShadowBackendAdmission } from '../../../render/src/render-pipeline';
9
11
 
10
12
  /**
11
13
  * Pure helper mirroring urp-pipeline.ts atlas sizing logic.
@@ -176,3 +178,42 @@ describe('CSM atlas layout (w13)', () => {
176
178
  });
177
179
  });
178
180
  });
181
+
182
+ describe('M3 inspection zero-delta structural budget', () => {
183
+ it('keeps PCF and PCSS on the same atlas and writer budget', () => {
184
+ const common = {
185
+ cascadeCount: 4,
186
+ mapSize: 2048,
187
+ atlasBytes: 67_108_864,
188
+ writerPasses: 4,
189
+ deviceGeneration: 1,
190
+ graphGeneration: 2,
191
+ } as const;
192
+ const pcf = projectDirectionalShadowInspection({
193
+ admission: resolveDirectionalShadowBackendAdmission({
194
+ backendKind: 'webgpu',
195
+ requested: 'pcf5',
196
+ candidate: 'accepted',
197
+ }),
198
+ ...common,
199
+ blockerTaps: 0,
200
+ filterTapUpperBound: 25,
201
+ seamTapUpperBound: 25,
202
+ });
203
+ const pcss = projectDirectionalShadowInspection({
204
+ admission: resolveDirectionalShadowBackendAdmission({
205
+ backendKind: 'webgpu',
206
+ requested: 'pcssHigh',
207
+ candidate: 'accepted',
208
+ }),
209
+ ...common,
210
+ blockerTaps: 16,
211
+ filterTapUpperBound: 32,
212
+ seamTapUpperBound: 96,
213
+ });
214
+ expect(pcss).toMatchObject({ effective: 'pcssHigh', cascadeCount: 4, mapSize: 2048 });
215
+ expect(pcf.writerPasses).toBe(pcss.writerPasses);
216
+ expect(pcf.atlasBytes).toBe(pcss.atlasBytes);
217
+ expect(pcf.graphGeneration).toBe(pcss.graphGeneration);
218
+ });
219
+ });
@@ -215,6 +215,7 @@ async function importEcs(): Promise<{ World: new () => unknown }> {
215
215
 
216
216
  async function importComponents(): Promise<{
217
217
  Transform: unknown;
218
+ GlobalTransform: unknown;
218
219
  MeshFilter: unknown;
219
220
  MeshRenderer: unknown;
220
221
  Camera: unknown;
@@ -83,7 +83,11 @@ describe('CSM component schema (w1)', () => {
83
83
  error: {
84
84
  code?: string;
85
85
  hint?: string;
86
- detail?: { field?: string; value?: number; min?: number; max?: number };
86
+ detail?: {
87
+ field?: string;
88
+ actual?: number;
89
+ bound?: { kind?: string; min?: number; max?: number };
90
+ };
87
91
  };
88
92
  };
89
93
 
@@ -97,7 +101,7 @@ describe('CSM component schema (w1)', () => {
97
101
  expect(r.error.code).toBe('shadow-invalid-config');
98
102
  expect(r.error.hint).toContain('cascadeCount');
99
103
  expect(r.error.detail?.field).toBe('cascadeCount');
100
- expect(r.error.detail?.value).toBe(0);
104
+ expect(r.error.detail?.actual).toBe(0);
101
105
  });
102
106
 
103
107
  it('cascadeCount=5 -> ShadowInvalidConfigError (max=4)', () => {
@@ -110,8 +114,8 @@ describe('CSM component schema (w1)', () => {
110
114
  expect(r.error.code).toBe('shadow-invalid-config');
111
115
  expect(r.error.hint).toContain('cascadeCount');
112
116
  expect(r.error.detail?.field).toBe('cascadeCount');
113
- expect(r.error.detail?.value).toBe(5);
114
- expect(r.error.detail?.max).toBe(4);
117
+ expect(r.error.detail?.actual).toBe(5);
118
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 1, max: 4 });
115
119
  });
116
120
 
117
121
  it('splitLambda=-0.1 -> ShadowInvalidConfigError (min=0)', () => {
@@ -124,7 +128,8 @@ describe('CSM component schema (w1)', () => {
124
128
  expect(r.error.code).toBe('shadow-invalid-config');
125
129
  expect(r.error.hint).toContain('splitLambda');
126
130
  expect(r.error.detail?.field).toBe('splitLambda');
127
- expect(r.error.detail?.value).toBeCloseTo(-0.1, 5);
131
+ expect(r.error.detail?.actual).toBeCloseTo(-0.1, 5);
132
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 1 });
128
133
  });
129
134
 
130
135
  it('splitLambda=1.1 -> ShadowInvalidConfigError (max=1)', () => {
@@ -137,8 +142,8 @@ describe('CSM component schema (w1)', () => {
137
142
  expect(r.error.code).toBe('shadow-invalid-config');
138
143
  expect(r.error.hint).toContain('splitLambda');
139
144
  expect(r.error.detail?.field).toBe('splitLambda');
140
- expect(r.error.detail?.value).toBeCloseTo(1.1, 5);
141
- expect(r.error.detail?.max).toBe(1);
145
+ expect(r.error.detail?.actual).toBeCloseTo(1.1, 5);
146
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 1 });
142
147
  });
143
148
 
144
149
  it('cascadeBlend=-0.1 -> ShadowInvalidConfigError (min=0)', () => {
@@ -151,7 +156,8 @@ describe('CSM component schema (w1)', () => {
151
156
  expect(r.error.code).toBe('shadow-invalid-config');
152
157
  expect(r.error.hint).toContain('cascadeBlend');
153
158
  expect(r.error.detail?.field).toBe('cascadeBlend');
154
- expect(r.error.detail?.value).toBeCloseTo(-0.1, 5);
159
+ expect(r.error.detail?.actual).toBeCloseTo(-0.1, 5);
160
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
155
161
  });
156
162
 
157
163
  it('cascadeBlend=0.6 -> ShadowInvalidConfigError (max=0.5)', () => {
@@ -164,8 +170,8 @@ describe('CSM component schema (w1)', () => {
164
170
  expect(r.error.code).toBe('shadow-invalid-config');
165
171
  expect(r.error.hint).toContain('cascadeBlend');
166
172
  expect(r.error.detail?.field).toBe('cascadeBlend');
167
- expect(r.error.detail?.value).toBeCloseTo(0.6, 5);
168
- expect(r.error.detail?.max).toBeCloseTo(0.5, 5);
173
+ expect(r.error.detail?.actual).toBeCloseTo(0.6, 5);
174
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
169
175
  });
170
176
 
171
177
  it('all three new fields within valid range spawn succeeds', () => {
@@ -180,29 +186,28 @@ describe('CSM component schema (w1)', () => {
180
186
 
181
187
  // ── w2: ShadowInvalidConfigDetail.max field constructor tests ──────────
182
188
 
183
- describe('w2: ShadowInvalidConfigDetail.max constructor', () => {
184
- it('cascadeCount=5 detail.max=4 via constructor', () => {
189
+ describe('w2: ShadowInvalidConfigDetail.bound constructor', () => {
190
+ it('cascadeCount=5 bound.max=4 via constructor', () => {
185
191
  // TDD red: max param not yet on constructor.
186
192
  const err = new (ShadowInvalidConfigError as any)('cascadeCount', 5, 1, 4);
187
193
  expect(err.code).toBe('shadow-invalid-config');
188
194
  expect(err.detail.field).toBe('cascadeCount');
189
- expect(err.detail.value).toBe(5);
190
- expect(err.detail.min).toBe(1);
191
- expect(err.detail.max).toBe(4);
195
+ expect(err.detail.actual).toBe(5);
196
+ expect(err.detail.bound).toEqual({ kind: 'range', min: 1, max: 4 });
192
197
  expect(err.hint).toContain('[1, 4]');
193
198
  });
194
199
 
195
- it('cascadeBlend=0.6 detail.max=0.5 via constructor', () => {
200
+ it('cascadeBlend=0.6 bound.max=0.5 via constructor', () => {
196
201
  const err = new (ShadowInvalidConfigError as any)('cascadeBlend', 0.6, 0, 0.5);
197
202
  expect(err.detail.field).toBe('cascadeBlend');
198
- expect(err.detail.max).toBeCloseTo(0.5, 5);
203
+ expect(err.detail.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
199
204
  expect(err.hint).toContain('[0, 0.5]');
200
205
  });
201
206
 
202
- it('splitLambda=1.1 detail.max=1 via constructor', () => {
207
+ it('splitLambda=1.1 bound.max=1 via constructor', () => {
203
208
  const err = new (ShadowInvalidConfigError as any)('splitLambda', 1.1, 0, 1);
204
209
  expect(err.detail.field).toBe('splitLambda');
205
- expect(err.detail.max).toBe(1);
210
+ expect(err.detail.bound).toEqual({ kind: 'range', min: 0, max: 1 });
206
211
  expect(err.hint).toContain('[0, 1]');
207
212
  });
208
213
  });
@@ -260,15 +265,17 @@ describe('CSM component schema (w1)', () => {
260
265
  const err = r.error as {
261
266
  code?: string;
262
267
  hint?: string;
263
- detail?: { field?: string; value?: number; min?: number; max?: number };
268
+ detail?: {
269
+ field?: string;
270
+ actual?: number;
271
+ bound?: { kind?: string; min?: number; max?: number };
272
+ };
264
273
  };
265
274
  expect(err.code).toBe('shadow-invalid-config');
266
275
  expect(err.hint).toContain('mapSize');
267
276
  expect(err.detail?.field).toBe('mapSize');
268
- expect(err.detail?.value).toBe(0);
269
- expect(err.detail?.min).toBe(1);
270
- // mapSize < 1 has no max (lower-bound only), compatible with old callers.
271
- expect(err.detail?.max).toBeUndefined();
277
+ expect(err.detail?.actual).toBe(0);
278
+ expect(err.detail?.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1 });
272
279
  }
273
280
  });
274
281