@forgeax/engine-rhi 0.0.0-dev.8d955ade1c79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +234 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/async-form.test-d.d.ts +2 -0
- package/dist/__tests__/async-form.test-d.d.ts.map +1 -0
- package/dist/__tests__/binding-resource.test-d.d.ts +2 -0
- package/dist/__tests__/binding-resource.test-d.d.ts.map +1 -0
- package/dist/__tests__/buffer-mapping.test-d.d.ts +2 -0
- package/dist/__tests__/buffer-mapping.test-d.d.ts.map +1 -0
- package/dist/__tests__/canvasContext.test-d.d.ts +2 -0
- package/dist/__tests__/canvasContext.test-d.d.ts.map +1 -0
- package/dist/__tests__/caps-three-way.unit.test.d.ts +2 -0
- package/dist/__tests__/caps-three-way.unit.test.d.ts.map +1 -0
- package/dist/__tests__/caps.test-d.d.ts +2 -0
- package/dist/__tests__/caps.test-d.d.ts.map +1 -0
- package/dist/__tests__/command-encoder-spec-alignment.test-d.d.ts +2 -0
- package/dist/__tests__/command-encoder-spec-alignment.test-d.d.ts.map +1 -0
- package/dist/__tests__/createComputePipeline.test-d.d.ts +2 -0
- package/dist/__tests__/createComputePipeline.test-d.d.ts.map +1 -0
- package/dist/__tests__/createQuerySet.test-d.d.ts +2 -0
- package/dist/__tests__/createQuerySet.test-d.d.ts.map +1 -0
- package/dist/__tests__/createTextureView.test-d.d.ts +2 -0
- package/dist/__tests__/createTextureView.test-d.d.ts.map +1 -0
- package/dist/__tests__/descriptor-mirror.test-d.d.ts +2 -0
- package/dist/__tests__/descriptor-mirror.test-d.d.ts.map +1 -0
- package/dist/__tests__/draw-validation-errors.test.d.ts +2 -0
- package/dist/__tests__/draw-validation-errors.test.d.ts.map +1 -0
- package/dist/__tests__/errors-instancing.unit.test.d.ts +2 -0
- package/dist/__tests__/errors-instancing.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.test-d.d.ts +2 -0
- package/dist/__tests__/errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/limit-exceeded-detail.test-d.d.ts +2 -0
- package/dist/__tests__/limit-exceeded-detail.test-d.d.ts.map +1 -0
- package/dist/__tests__/mapped-buffer.test-d.d.ts +2 -0
- package/dist/__tests__/mapped-buffer.test-d.d.ts.map +1 -0
- package/dist/__tests__/opaque-handle.test-d.d.ts +2 -0
- package/dist/__tests__/opaque-handle.test-d.d.ts.map +1 -0
- package/dist/__tests__/owner-out-of-range-role.test.d.ts +2 -0
- package/dist/__tests__/owner-out-of-range-role.test.d.ts.map +1 -0
- package/dist/__tests__/queue-writes.test-d.d.ts +2 -0
- package/dist/__tests__/queue-writes.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-pass-encoder-spec-alignment.test-d.d.ts +2 -0
- package/dist/__tests__/render-pass-encoder-spec-alignment.test-d.d.ts.map +1 -0
- package/dist/__tests__/requestAdapter.test-d.d.ts +2 -0
- package/dist/__tests__/requestAdapter.test-d.d.ts.map +1 -0
- package/dist/errors.d.ts +315 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.mjs +56 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.d.ts +1758 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +87 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +61 -0
- package/src/__tests__/async-form.test-d.ts +40 -0
- package/src/__tests__/binding-resource.test-d.ts +66 -0
- package/src/__tests__/buffer-mapping.test-d.ts +84 -0
- package/src/__tests__/canvasContext.test-d.ts +106 -0
- package/src/__tests__/caps-three-way.unit.test.ts +144 -0
- package/src/__tests__/caps.test-d.ts +83 -0
- package/src/__tests__/command-encoder-spec-alignment.test-d.ts +117 -0
- package/src/__tests__/createComputePipeline.test-d.ts +149 -0
- package/src/__tests__/createQuerySet.test-d.ts +81 -0
- package/src/__tests__/createTextureView.test-d.ts +127 -0
- package/src/__tests__/descriptor-mirror.test-d.ts +284 -0
- package/src/__tests__/draw-validation-errors.test.ts +155 -0
- package/src/__tests__/errors-instancing.unit.test.ts +167 -0
- package/src/__tests__/errors.test-d.ts +199 -0
- package/src/__tests__/limit-exceeded-detail.test-d.ts +33 -0
- package/src/__tests__/mapped-buffer.test-d.ts +65 -0
- package/src/__tests__/opaque-handle.test-d.ts +121 -0
- package/src/__tests__/owner-out-of-range-role.test.ts +84 -0
- package/src/__tests__/queue-writes.test-d.ts +108 -0
- package/src/__tests__/render-pass-encoder-spec-alignment.test-d.ts +202 -0
- package/src/__tests__/requestAdapter.test-d.ts +123 -0
- package/src/errors.ts +504 -0
- package/src/index.ts +2276 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// feat-20260708-composited-multi-world-rendering M3 / m3-t1 — AC-10 two new
|
|
2
|
+
// RhiErrorCode members + the pure draw-args validation helper.
|
|
3
|
+
//
|
|
4
|
+
// Plan-strategy 2 D-5: renderer.draw(worlds, { cameraOwner, resourceOwner }) validates its inputs at
|
|
5
|
+
// the entry (before any extract) and returns Result.err on:
|
|
6
|
+
// - empty worlds array -> 'render-system-empty-worlds'
|
|
7
|
+
// - owner index out of range -> 'render-system-owner-out-of-range'
|
|
8
|
+
// (.detail = { owner, worldCount })
|
|
9
|
+
//
|
|
10
|
+
// The two codes are NOT mutually exclusive: an empty array with an out-of-range
|
|
11
|
+
// owner surfaces the empty-worlds code first (entry check short-circuits before
|
|
12
|
+
// the owner-range check). requirements AC-10: illegal input is a structured,
|
|
13
|
+
// property-accessible failure (.code / .hint / .detail) — never silent.
|
|
14
|
+
//
|
|
15
|
+
// Why the validation lives in @forgeax/engine-rhi (World-free primitives, not
|
|
16
|
+
// @forgeax/engine-runtime): rhi owns the RhiErrorCode closed union (SSOT), and
|
|
17
|
+
// this package depends only on @forgeax/engine-types — it cannot import the
|
|
18
|
+
// runtime Renderer. `validateDrawArgs(worldCount, owner)` takes plain numbers
|
|
19
|
+
// (no World, no math), so it belongs with the codes it emits; createRenderer's
|
|
20
|
+
// draw entry calls it (D-5 "validate at the draw entry"). This keeps the code
|
|
21
|
+
// + validation + test in one package (architecture-principles §1 SSOT + §3
|
|
22
|
+
// Schema as Contract: the validator is the machine-checkable contract).
|
|
23
|
+
//
|
|
24
|
+
// AC-01 single path: only the explicit camera/resource owner form exists; there
|
|
25
|
+
// is no draw(world) overload. AC-02's owner-required compile-time narrowing is
|
|
26
|
+
// verified at the real hello-triangle callsite (m3-t2), not here.
|
|
27
|
+
|
|
28
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
29
|
+
import {
|
|
30
|
+
RhiError,
|
|
31
|
+
type RhiErrorCode,
|
|
32
|
+
type RhiErrorDetail,
|
|
33
|
+
type RhiOwnerOutOfRangeDetail,
|
|
34
|
+
validateDrawArgs,
|
|
35
|
+
} from '../errors';
|
|
36
|
+
|
|
37
|
+
describe('M3 / m3-t1 — two new RhiErrorCode members', () => {
|
|
38
|
+
it("contains 'render-system-empty-worlds' in the closed RhiErrorCode union", () => {
|
|
39
|
+
expectTypeOf<'render-system-empty-worlds'>().toMatchTypeOf<RhiErrorCode>();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("contains 'render-system-owner-out-of-range' in the closed RhiErrorCode union", () => {
|
|
43
|
+
expectTypeOf<'render-system-owner-out-of-range'>().toMatchTypeOf<RhiErrorCode>();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('the two new members are distinct from each other', () => {
|
|
47
|
+
type Empty = Extract<RhiErrorCode, 'render-system-empty-worlds'>;
|
|
48
|
+
type Oob = Extract<RhiErrorCode, 'render-system-owner-out-of-range'>;
|
|
49
|
+
expectTypeOf<Empty>().not.toEqualTypeOf<Oob>();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('exhaustive switch reaches both new members without a default arm', () => {
|
|
53
|
+
// A narrow switch over just the two new members: tsc proves both literals
|
|
54
|
+
// are members of the union (otherwise the case labels would not compile);
|
|
55
|
+
// the runtime assertion proves the mapping is reachable.
|
|
56
|
+
function describeNew(
|
|
57
|
+
code: 'render-system-empty-worlds' | 'render-system-owner-out-of-range',
|
|
58
|
+
): string {
|
|
59
|
+
switch (code) {
|
|
60
|
+
case 'render-system-empty-worlds':
|
|
61
|
+
return 'empty';
|
|
62
|
+
case 'render-system-owner-out-of-range':
|
|
63
|
+
return 'oob';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
expect(describeNew('render-system-empty-worlds')).toBe('empty');
|
|
67
|
+
expect(describeNew('render-system-owner-out-of-range')).toBe('oob');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('M3 / m3-t1 — RhiOwnerOutOfRangeDetail shape', () => {
|
|
72
|
+
// feat-20260709-editor-world-partition M1 / w7: the detail grew a `role`
|
|
73
|
+
// discriminator when the single draw owner was split into cameraOwner +
|
|
74
|
+
// resourceOwner (plan-strategy §2 D-3; new SSOT contract in
|
|
75
|
+
// owner-out-of-range-role.test.ts). This precursor shape assertion is
|
|
76
|
+
// migrated to the two-index form — no new error code, only `.detail` grows.
|
|
77
|
+
it('detail equals { role, owner: number, worldCount: number }', () => {
|
|
78
|
+
expectTypeOf<RhiOwnerOutOfRangeDetail>().toEqualTypeOf<{
|
|
79
|
+
readonly role: 'camera' | 'resource';
|
|
80
|
+
readonly owner: number;
|
|
81
|
+
readonly worldCount: number;
|
|
82
|
+
}>();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('detail is a member of the RhiErrorDetail discriminated union', () => {
|
|
86
|
+
const detail: RhiOwnerOutOfRangeDetail = { role: 'resource', owner: 2, worldCount: 1 };
|
|
87
|
+
const widened: RhiErrorDetail = detail;
|
|
88
|
+
expectTypeOf(widened).toMatchTypeOf<RhiErrorDetail>();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('M3 / m3-t1 — validateDrawArgs (D-5 draw entry validation)', () => {
|
|
93
|
+
it('empty worlds -> err render-system-empty-worlds with a non-empty hint', () => {
|
|
94
|
+
const r = validateDrawArgs(0, { cameraOwner: 0, resourceOwner: 0 });
|
|
95
|
+
expect(r.ok).toBe(false);
|
|
96
|
+
if (!r.ok) {
|
|
97
|
+
expect(r.error.code).toBe('render-system-empty-worlds');
|
|
98
|
+
expect(r.error.hint.length).toBeGreaterThan(0);
|
|
99
|
+
// AI users recover from the hint text (charter P3 actionable hint).
|
|
100
|
+
expect(r.error.hint).toContain('at least one world');
|
|
101
|
+
// empty-worlds carries no detail (the failure is fully described by .code).
|
|
102
|
+
expect(r.error.detail).toBeUndefined();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('owner out of range (owner >= worldCount) -> err with detail { owner, worldCount }', () => {
|
|
107
|
+
const r = validateDrawArgs(1, { cameraOwner: 1, resourceOwner: 0 });
|
|
108
|
+
expect(r.ok).toBe(false);
|
|
109
|
+
if (!r.ok) {
|
|
110
|
+
expect(r.error.code).toBe('render-system-owner-out-of-range');
|
|
111
|
+
expect(r.error.hint.length).toBeGreaterThan(0);
|
|
112
|
+
expect(r.error.hint).toContain('0..worlds.length-1');
|
|
113
|
+
// .detail is reachable via property access after narrowing on .code.
|
|
114
|
+
const d = r.error.detail as RhiOwnerOutOfRangeDetail;
|
|
115
|
+
expect(d.owner).toBe(1);
|
|
116
|
+
expect(d.worldCount).toBe(1);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('owner out of range (owner < 0) -> err render-system-owner-out-of-range', () => {
|
|
121
|
+
const r = validateDrawArgs(2, { cameraOwner: -1, resourceOwner: 0 });
|
|
122
|
+
expect(r.ok).toBe(false);
|
|
123
|
+
if (!r.ok) {
|
|
124
|
+
expect(r.error.code).toBe('render-system-owner-out-of-range');
|
|
125
|
+
const d = r.error.detail as RhiOwnerOutOfRangeDetail;
|
|
126
|
+
expect(d.owner).toBe(-1);
|
|
127
|
+
expect(d.worldCount).toBe(2);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('two codes are non-exclusive: empty array + out-of-range owner short-circuits to empty-worlds', () => {
|
|
132
|
+
// Entry check order: the empty-worlds guard runs first, so an empty array
|
|
133
|
+
// with a nonsense owner (5) never reaches the owner-range branch (D-5).
|
|
134
|
+
const r = validateDrawArgs(0, { cameraOwner: 5, resourceOwner: 5 });
|
|
135
|
+
expect(r.ok).toBe(false);
|
|
136
|
+
if (!r.ok) {
|
|
137
|
+
expect(r.error.code).toBe('render-system-empty-worlds');
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('valid args (owner within [0, worldCount)) -> ok', () => {
|
|
142
|
+
expect(validateDrawArgs(1, { cameraOwner: 0, resourceOwner: 0 }).ok).toBe(true);
|
|
143
|
+
expect(validateDrawArgs(3, { cameraOwner: 0, resourceOwner: 0 }).ok).toBe(true);
|
|
144
|
+
expect(validateDrawArgs(3, { cameraOwner: 2, resourceOwner: 2 }).ok).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returned errors are RhiError instances (structured, not thrown strings)', () => {
|
|
148
|
+
const r = validateDrawArgs(1, { cameraOwner: 9, resourceOwner: 0 });
|
|
149
|
+
expect(r.ok).toBe(false);
|
|
150
|
+
if (!r.ok) {
|
|
151
|
+
expect(r.error).toBeInstanceOf(RhiError);
|
|
152
|
+
expect(r.error.expected.length).toBeGreaterThan(0);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// feat-20260622-chunk-gpu-instancing-sprite-tilemap M2 / w8 — RhiErrorCode
|
|
2
|
+
// 21st member ('instancing-exceeds-uniform-cap') + .detail shape unit tests.
|
|
3
|
+
//
|
|
4
|
+
// Plan-strategy 2 D-2: closed RhiErrorCode add-only minor evolution; AC-05 (a)
|
|
5
|
+
// surface — RhiInstancingExceedsUniformCapDetail tagged union member with
|
|
6
|
+
// fields { requested: number, limit: 128, scope: 'sprite' | 'tilemap-chunk' }.
|
|
7
|
+
//
|
|
8
|
+
// AI-user contract (charter proposition 4 explicit failure + plan-strategy
|
|
9
|
+
// 8.3 actionable hint): consumers narrow .detail through property access
|
|
10
|
+
// (err.detail.requested / err.detail.limit / err.detail.scope) instead of
|
|
11
|
+
// parsing err.message string. The 128-instance cap originates from the
|
|
12
|
+
// uniform-fallback path's WebGL2 minimum 16384B UBO size — research N-1
|
|
13
|
+
// observed value (128 * 64B = 8192B leaves headroom for the per-frame
|
|
14
|
+
// material UBO slice). research F-4 + plan-decisions D-3 lock the limit.
|
|
15
|
+
//
|
|
16
|
+
// The runtime-shape assertions (sections 1 + 2) double as compile-time
|
|
17
|
+
// guarantees: if the union member or detail shape regress, tsc + vitest
|
|
18
|
+
// both flip red. Section 3 protects pre-existing 'limit-exceeded' detail
|
|
19
|
+
// from incidental drift (LimitExceededDetail must keep
|
|
20
|
+
// { maxStorageBufferBindingSize, requestedBytes } per
|
|
21
|
+
// feat-20260513-instanced-mesh M5 reshape).
|
|
22
|
+
|
|
23
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
24
|
+
import {
|
|
25
|
+
type LimitExceededDetail,
|
|
26
|
+
RhiError,
|
|
27
|
+
type RhiErrorCode,
|
|
28
|
+
type RhiErrorDetail,
|
|
29
|
+
type RhiInstancingExceedsUniformCapDetail,
|
|
30
|
+
} from '../errors';
|
|
31
|
+
|
|
32
|
+
describe('M2 / w8 — RhiErrorCode 21st member instancing-exceeds-uniform-cap', () => {
|
|
33
|
+
it('contains instancing-exceeds-uniform-cap in the closed RhiErrorCode union', () => {
|
|
34
|
+
expectTypeOf<'instancing-exceeds-uniform-cap'>().toMatchTypeOf<RhiErrorCode>();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('exhaustive switch over all 21 members compiles without default fallback', () => {
|
|
38
|
+
function describeCode(code: RhiErrorCode): string {
|
|
39
|
+
switch (code) {
|
|
40
|
+
case 'adapter-unavailable':
|
|
41
|
+
return 'adapter';
|
|
42
|
+
case 'feature-not-enabled':
|
|
43
|
+
return 'feature';
|
|
44
|
+
case 'limit-exceeded':
|
|
45
|
+
return 'limit';
|
|
46
|
+
case 'shader-compile-failed':
|
|
47
|
+
return 'shader';
|
|
48
|
+
case 'rhi-not-available':
|
|
49
|
+
return 'reserved';
|
|
50
|
+
case 'webgpu-runtime-error':
|
|
51
|
+
return 'webgpu-runtime';
|
|
52
|
+
case 'command-encoder-finished':
|
|
53
|
+
return 'encoder-finished';
|
|
54
|
+
case 'render-pass-not-ended':
|
|
55
|
+
return 'pass-not-ended';
|
|
56
|
+
case 'queue-submit-failed':
|
|
57
|
+
return 'queue-submit';
|
|
58
|
+
case 'queue-write-buffer-out-of-bounds':
|
|
59
|
+
return 'queue-bounds';
|
|
60
|
+
case 'render-system-no-camera':
|
|
61
|
+
return 'no-camera';
|
|
62
|
+
case 'render-system-multi-camera':
|
|
63
|
+
return 'multi-camera';
|
|
64
|
+
case 'render-system-multi-light':
|
|
65
|
+
return 'multi-light';
|
|
66
|
+
case 'asset-not-registered':
|
|
67
|
+
return 'asset-miss';
|
|
68
|
+
case 'device-lost':
|
|
69
|
+
return 'device-lost';
|
|
70
|
+
case 'oom':
|
|
71
|
+
return 'oom';
|
|
72
|
+
case 'internal-error':
|
|
73
|
+
return 'internal-error';
|
|
74
|
+
case 'hierarchy-broken':
|
|
75
|
+
return 'hierarchy-broken';
|
|
76
|
+
case 'destroy-after-destroy':
|
|
77
|
+
return 'destroy-after-destroy';
|
|
78
|
+
case 'rhi-descriptor-invalid':
|
|
79
|
+
return 'descriptor-invalid';
|
|
80
|
+
case 'instancing-exceeds-uniform-cap':
|
|
81
|
+
return 'instancing-cap';
|
|
82
|
+
case 'render-system-empty-worlds':
|
|
83
|
+
return 'empty-worlds';
|
|
84
|
+
case 'render-system-owner-out-of-range':
|
|
85
|
+
return 'owner-oob';
|
|
86
|
+
}
|
|
87
|
+
// No default — tsc strict guards: union drift here triggers compile-time red.
|
|
88
|
+
}
|
|
89
|
+
expect(describeCode('instancing-exceeds-uniform-cap')).toBe('instancing-cap');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('M2 / w8 — RhiInstancingExceedsUniformCapDetail shape', () => {
|
|
94
|
+
it('detail equals { requested: number, limit: 128, scope: sprite|tilemap-chunk }', () => {
|
|
95
|
+
expectTypeOf<RhiInstancingExceedsUniformCapDetail>().toEqualTypeOf<{
|
|
96
|
+
readonly requested: number;
|
|
97
|
+
readonly limit: 128;
|
|
98
|
+
readonly scope: 'sprite' | 'tilemap-chunk';
|
|
99
|
+
}>();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('detail is a member of the RhiErrorDetail discriminated union', () => {
|
|
103
|
+
const detail: RhiInstancingExceedsUniformCapDetail = {
|
|
104
|
+
requested: 200,
|
|
105
|
+
limit: 128,
|
|
106
|
+
scope: 'sprite',
|
|
107
|
+
};
|
|
108
|
+
const widened: RhiErrorDetail = detail;
|
|
109
|
+
expectTypeOf(widened).toMatchTypeOf<RhiErrorDetail>();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('limit field is the literal 128, not a generic number', () => {
|
|
113
|
+
// tsc will reject `limit: 256` etc. — locked to the literal 128.
|
|
114
|
+
const detail: RhiInstancingExceedsUniformCapDetail = {
|
|
115
|
+
requested: 1024,
|
|
116
|
+
limit: 128,
|
|
117
|
+
scope: 'tilemap-chunk',
|
|
118
|
+
};
|
|
119
|
+
expect(detail.limit).toBe(128);
|
|
120
|
+
expectTypeOf<RhiInstancingExceedsUniformCapDetail['limit']>().toEqualTypeOf<128>();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('scope field is the closed sprite | tilemap-chunk literal union', () => {
|
|
124
|
+
expectTypeOf<RhiInstancingExceedsUniformCapDetail['scope']>().toEqualTypeOf<
|
|
125
|
+
'sprite' | 'tilemap-chunk'
|
|
126
|
+
>();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('AI-user property-access consumption path: read .requested / .limit / .scope', () => {
|
|
130
|
+
const err = new RhiError({
|
|
131
|
+
code: 'instancing-exceeds-uniform-cap',
|
|
132
|
+
expected: 'bucket instance count <= 128 (uniform fallback cap)',
|
|
133
|
+
hint: 'reduce bucket size, switch to a WebGPU-capable backend, or accept per-cell fallback',
|
|
134
|
+
detail: { requested: 256, limit: 128, scope: 'tilemap-chunk' },
|
|
135
|
+
});
|
|
136
|
+
expect(err.code).toBe('instancing-exceeds-uniform-cap');
|
|
137
|
+
if (err.code === 'instancing-exceeds-uniform-cap') {
|
|
138
|
+
// After narrowing on .code, .detail is reachable via property access
|
|
139
|
+
// — no string parsing of err.message.
|
|
140
|
+
const d = err.detail as RhiInstancingExceedsUniformCapDetail;
|
|
141
|
+
expect(d.requested).toBe(256);
|
|
142
|
+
expect(d.limit).toBe(128);
|
|
143
|
+
expect(d.scope).toBe('tilemap-chunk');
|
|
144
|
+
}
|
|
145
|
+
expect(err.expected.length).toBeGreaterThan(0);
|
|
146
|
+
expect(err.hint.length).toBeGreaterThan(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('M2 / w8 — pre-existing limit-exceeded detail unchanged', () => {
|
|
151
|
+
it('LimitExceededDetail keeps { maxStorageBufferBindingSize, requestedBytes }', () => {
|
|
152
|
+
expectTypeOf<LimitExceededDetail>().toEqualTypeOf<{
|
|
153
|
+
readonly maxStorageBufferBindingSize: number;
|
|
154
|
+
readonly requestedBytes: number;
|
|
155
|
+
}>();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("'limit-exceeded' member remains in the closed union (no rename / reorder)", () => {
|
|
159
|
+
expectTypeOf<'limit-exceeded'>().toMatchTypeOf<RhiErrorCode>();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('limit-exceeded and instancing-exceeds-uniform-cap are distinct members', () => {
|
|
163
|
+
type Le = Extract<RhiErrorCode, 'limit-exceeded'>;
|
|
164
|
+
type Ic = Extract<RhiErrorCode, 'instancing-exceeds-uniform-cap'>;
|
|
165
|
+
expectTypeOf<Le>().not.toEqualTypeOf<Ic>();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// MVP-1.7 (type-level) — closed RhiErrorCode union completeness + exhaustive switch
|
|
2
|
+
// without default fallback. Round 1 baseline: 6 members; extended to 10 members in
|
|
3
|
+
// feat-20260508-rhi-surface-completion w7 (D-S3); extended to 14 members in
|
|
4
|
+
// feat-20260509-ecs-render-bridge-mvp w6 (D-S7); extended to 17 members in
|
|
5
|
+
// feat-20260511-rhi-spec-realign-aggressive w1 (red) -> w6 (green) per
|
|
6
|
+
// plan-strategy D-P4 + requirements AC-04. Three new members map W3C WebGPU
|
|
7
|
+
// 22.2 spec subtypes (device-lost / oom / internal-error) to the forgeax union.
|
|
8
|
+
// Extended to 20 members in feat-20260619-wasm-fault-isolation M3 w7:
|
|
9
|
+
// 'rhi-descriptor-invalid' for wgpu-wasm descriptor parse failures.
|
|
10
|
+
//
|
|
11
|
+
// 20 members:
|
|
12
|
+
// 1) 'adapter-unavailable' (Round 1 baseline)
|
|
13
|
+
// 2) 'feature-not-enabled' (Round 1 baseline)
|
|
14
|
+
// 3) 'limit-exceeded' (Round 1 baseline)
|
|
15
|
+
// 4) 'shader-compile-failed' (Round 1 baseline)
|
|
16
|
+
// 5) 'rhi-not-available' (Round 1 baseline; @reserved + placeholder)
|
|
17
|
+
// 6) 'webgpu-runtime-error' (verify-gpu-smoke-gate K-9 sixth member)
|
|
18
|
+
// 7) 'command-encoder-finished' (rhi-surface-completion D-S3)
|
|
19
|
+
// 8) 'render-pass-not-ended' (rhi-surface-completion D-S3)
|
|
20
|
+
// 9) 'queue-submit-failed' (rhi-surface-completion D-S3)
|
|
21
|
+
// 10) 'queue-write-buffer-out-of-bounds' (rhi-surface-completion D-S3)
|
|
22
|
+
// 11) 'render-system-no-camera' (ecs-render-bridge-mvp D-S7)
|
|
23
|
+
// 12) 'render-system-multi-camera' (ecs-render-bridge-mvp D-S7)
|
|
24
|
+
// 13) 'render-system-multi-light' (ecs-render-bridge-mvp D-S7)
|
|
25
|
+
// 14) 'asset-not-registered' (ecs-render-bridge-mvp D-S7)
|
|
26
|
+
// 15) 'device-lost' (rhi-spec-realign-aggressive D-P4 / R-02 §2.1)
|
|
27
|
+
// 16) 'oom' (rhi-spec-realign-aggressive D-P4 / R-02 §2.1)
|
|
28
|
+
// 17) 'internal-error' (rhi-spec-realign-aggressive D-P4 / R-02 §2.1)
|
|
29
|
+
// 18) 'hierarchy-broken' (asset-system-v1 w4 / D-P2 + requirements §9 row 8)
|
|
30
|
+
// 19) 'destroy-after-destroy' (feat-20260612-rhi-destroy-renderer-dispose-gpu-lifecycle M1 / D-6 + D-7)
|
|
31
|
+
// 20) 'rhi-descriptor-invalid' (feat-20260619-wasm-fault-isolation M3 w7 / D-1 + D-2 + D-8)
|
|
32
|
+
//
|
|
33
|
+
// charter mapping: proposition 4 (explicit failure via closed union) +
|
|
34
|
+
// proposition 3 (machine-readable union > prose) — switch (err.code) without
|
|
35
|
+
// default fallback; tsc strict mode rejects union drift at compile time.
|
|
36
|
+
//
|
|
37
|
+
// Related: requirements AC-04 (18-member union) + AC MVP-1.7 + AC-10 + AC-RSC-07;
|
|
38
|
+
// plan-strategy D-P4 (.expected / .hint templates) + D-P2 (hierarchy-broken) +
|
|
39
|
+
// S-6 (types/rhi single source) + 7.2 naming convention; research R-02 §2.1
|
|
40
|
+
// (W3C spec 22.2 subtypes).
|
|
41
|
+
|
|
42
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
43
|
+
import type { RhiError, RhiErrorCode } from '../errors';
|
|
44
|
+
|
|
45
|
+
describe('MVP-1.7 — RhiErrorCode closed union 20 members', () => {
|
|
46
|
+
it('contains adapter-unavailable', () => {
|
|
47
|
+
expectTypeOf<'adapter-unavailable'>().toMatchTypeOf<RhiErrorCode>();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('contains feature-not-enabled', () => {
|
|
51
|
+
expectTypeOf<'feature-not-enabled'>().toMatchTypeOf<RhiErrorCode>();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('contains limit-exceeded', () => {
|
|
55
|
+
expectTypeOf<'limit-exceeded'>().toMatchTypeOf<RhiErrorCode>();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('contains shader-compile-failed', () => {
|
|
59
|
+
expectTypeOf<'shader-compile-failed'>().toMatchTypeOf<RhiErrorCode>();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('contains rhi-not-available (@reserved + placeholder methods)', () => {
|
|
63
|
+
expectTypeOf<'rhi-not-available'>().toMatchTypeOf<RhiErrorCode>();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('contains webgpu-runtime-error (verify-gpu-smoke-gate K-9 sixth member)', () => {
|
|
67
|
+
expectTypeOf<'webgpu-runtime-error'>().toMatchTypeOf<RhiErrorCode>();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('contains command-encoder-finished (D-S3 new member)', () => {
|
|
71
|
+
expectTypeOf<'command-encoder-finished'>().toMatchTypeOf<RhiErrorCode>();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('contains render-pass-not-ended (D-S3 new member)', () => {
|
|
75
|
+
expectTypeOf<'render-pass-not-ended'>().toMatchTypeOf<RhiErrorCode>();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('contains queue-submit-failed (D-S3 new member)', () => {
|
|
79
|
+
expectTypeOf<'queue-submit-failed'>().toMatchTypeOf<RhiErrorCode>();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('contains queue-write-buffer-out-of-bounds (D-S3 new member)', () => {
|
|
83
|
+
expectTypeOf<'queue-write-buffer-out-of-bounds'>().toMatchTypeOf<RhiErrorCode>();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('contains render-system-no-camera (D-S7 new member)', () => {
|
|
87
|
+
expectTypeOf<'render-system-no-camera'>().toMatchTypeOf<RhiErrorCode>();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('contains render-system-multi-camera (D-S7 new member)', () => {
|
|
91
|
+
expectTypeOf<'render-system-multi-camera'>().toMatchTypeOf<RhiErrorCode>();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('contains render-system-multi-light (D-S7 new member)', () => {
|
|
95
|
+
expectTypeOf<'render-system-multi-light'>().toMatchTypeOf<RhiErrorCode>();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('contains asset-not-registered (D-S7 new member)', () => {
|
|
99
|
+
expectTypeOf<'asset-not-registered'>().toMatchTypeOf<RhiErrorCode>();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('contains device-lost (D-P4 new member; spec 22.1 device lost)', () => {
|
|
103
|
+
expectTypeOf<'device-lost'>().toMatchTypeOf<RhiErrorCode>();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('contains oom (D-P4 new member; spec 22.2 GPUOutOfMemoryError subtype)', () => {
|
|
107
|
+
expectTypeOf<'oom'>().toMatchTypeOf<RhiErrorCode>();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('contains internal-error (D-P4 new member; spec 22.2 GPUInternalError subtype)', () => {
|
|
111
|
+
expectTypeOf<'internal-error'>().toMatchTypeOf<RhiErrorCode>();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('contains hierarchy-broken (w4 asset-system-v1 D-P2 new member; propagateTransforms stale ChildOf ref)', () => {
|
|
115
|
+
expectTypeOf<'hierarchy-broken'>().toMatchTypeOf<RhiErrorCode>();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('contains destroy-after-destroy (feat-20260612 M1 / D-6 + D-7; double destroy fail-fast)', () => {
|
|
119
|
+
expectTypeOf<'destroy-after-destroy'>().toMatchTypeOf<RhiErrorCode>();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('contains rhi-descriptor-invalid (feat-20260619 M3 w7; wgpu-wasm descriptor parse failure)', () => {
|
|
123
|
+
expectTypeOf<'rhi-descriptor-invalid'>().toMatchTypeOf<RhiErrorCode>();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('union remains closed: rejects non-member literal', () => {
|
|
127
|
+
// @ts-expect-error MVP-1.7: union is closed — 'not-a-real-code' is not a member.
|
|
128
|
+
const _bogus: RhiErrorCode = 'not-a-real-code';
|
|
129
|
+
void _bogus;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('exhaustive switch with no default fallback for all members (charter proposition 4)', () => {
|
|
133
|
+
function describeCode(code: RhiErrorCode): string {
|
|
134
|
+
switch (code) {
|
|
135
|
+
case 'adapter-unavailable':
|
|
136
|
+
return 'adapter';
|
|
137
|
+
case 'feature-not-enabled':
|
|
138
|
+
return 'feature';
|
|
139
|
+
case 'limit-exceeded':
|
|
140
|
+
return 'limit';
|
|
141
|
+
case 'shader-compile-failed':
|
|
142
|
+
return 'shader';
|
|
143
|
+
case 'rhi-not-available':
|
|
144
|
+
return 'reserved';
|
|
145
|
+
case 'webgpu-runtime-error':
|
|
146
|
+
return 'webgpu-runtime';
|
|
147
|
+
case 'command-encoder-finished':
|
|
148
|
+
return 'encoder-finished';
|
|
149
|
+
case 'render-pass-not-ended':
|
|
150
|
+
return 'pass-not-ended';
|
|
151
|
+
case 'queue-submit-failed':
|
|
152
|
+
return 'queue-submit';
|
|
153
|
+
case 'queue-write-buffer-out-of-bounds':
|
|
154
|
+
return 'queue-bounds';
|
|
155
|
+
case 'render-system-no-camera':
|
|
156
|
+
return 'no-camera';
|
|
157
|
+
case 'render-system-multi-camera':
|
|
158
|
+
return 'multi-camera';
|
|
159
|
+
case 'render-system-multi-light':
|
|
160
|
+
return 'multi-light';
|
|
161
|
+
case 'asset-not-registered':
|
|
162
|
+
return 'asset-miss';
|
|
163
|
+
case 'device-lost':
|
|
164
|
+
return 'device-lost';
|
|
165
|
+
case 'oom':
|
|
166
|
+
return 'oom';
|
|
167
|
+
case 'internal-error':
|
|
168
|
+
return 'internal-error';
|
|
169
|
+
case 'hierarchy-broken':
|
|
170
|
+
return 'hierarchy-broken';
|
|
171
|
+
case 'destroy-after-destroy':
|
|
172
|
+
return 'destroy-after-destroy';
|
|
173
|
+
case 'rhi-descriptor-invalid':
|
|
174
|
+
return 'descriptor-invalid';
|
|
175
|
+
case 'instancing-exceeds-uniform-cap':
|
|
176
|
+
return 'instancing-cap';
|
|
177
|
+
case 'render-system-empty-worlds':
|
|
178
|
+
return 'empty-worlds';
|
|
179
|
+
case 'render-system-owner-out-of-range':
|
|
180
|
+
return 'owner-oob';
|
|
181
|
+
}
|
|
182
|
+
// No default — TS guards: union drift here triggers compile-time red.
|
|
183
|
+
}
|
|
184
|
+
expectTypeOf(describeCode).returns.toEqualTypeOf<string>();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('RhiError.code literal type is exactly RhiErrorCode union', () => {
|
|
188
|
+
expectTypeOf<RhiError['code']>().toEqualTypeOf<RhiErrorCode>();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('RhiError has readonly code / expected / hint fields', () => {
|
|
192
|
+
type ErrShape = {
|
|
193
|
+
readonly code: RhiErrorCode;
|
|
194
|
+
readonly expected: string;
|
|
195
|
+
readonly hint: string;
|
|
196
|
+
};
|
|
197
|
+
expectTypeOf<RhiError>().toMatchTypeOf<ErrShape>();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// feat-20260513-instanced-mesh M5 (T-M5-2): LimitExceededDetail field
|
|
2
|
+
// reshape — type-level assertions (vitest typecheck layer, *.test-d.ts).
|
|
3
|
+
//
|
|
4
|
+
// Companion to `limit-exceeded-detail.test.ts` (runtime). Vitest's
|
|
5
|
+
// typecheck pass (vitest 4.x default `typecheck.include === **/*.test-d.{ts,tsx}`)
|
|
6
|
+
// runs `expectTypeOf` here against the source LimitExceededDetail type;
|
|
7
|
+
// a legacy regression that re-introduces `renderableCount` / `limit`
|
|
8
|
+
// fields would flip these red.
|
|
9
|
+
//
|
|
10
|
+
// Plan rationale: requirements AC-15 (LimitExceededDetail field reshape
|
|
11
|
+
// evolution major) + plan-strategy D-3 (rename + replace, AGENTS.md
|
|
12
|
+
// Change stance — optimal > compatible).
|
|
13
|
+
|
|
14
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
15
|
+
import type { LimitExceededDetail, RhiErrorDetail } from '../errors';
|
|
16
|
+
|
|
17
|
+
describe('feat-20260513-instanced-mesh T-M5-2 LimitExceededDetail (type-level)', () => {
|
|
18
|
+
it('equals the new { maxStorageBufferBindingSize, requestedBytes } shape', () => {
|
|
19
|
+
expectTypeOf<LimitExceededDetail>().toEqualTypeOf<{
|
|
20
|
+
readonly maxStorageBufferBindingSize: number;
|
|
21
|
+
readonly requestedBytes: number;
|
|
22
|
+
}>();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('is a member of the RhiErrorDetail discriminated union', () => {
|
|
26
|
+
const detail: LimitExceededDetail = {
|
|
27
|
+
maxStorageBufferBindingSize: 256,
|
|
28
|
+
requestedBytes: 4096,
|
|
29
|
+
};
|
|
30
|
+
const widened: RhiErrorDetail = detail;
|
|
31
|
+
expectTypeOf(widened).toMatchTypeOf<RhiErrorDetail>();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Type-level — MappedBuffer brand + method form (this: MappedBuffer guard).
|
|
2
|
+
//
|
|
3
|
+
// Introduced in feat-20260511-rhi-spec-realign-aggressive w4 (red) -> w10
|
|
4
|
+
// (green) per requirements AC-11 + plan-strategy §7.1 MappedBuffer + D-P2
|
|
5
|
+
// break-point #6 (brand + method form merged into one row).
|
|
6
|
+
//
|
|
7
|
+
// Shape:
|
|
8
|
+
// - `MappedBuffer = Buffer & { readonly __mapped: void }`
|
|
9
|
+
// - `buffer.mapAsync(mode, ...)` returns `Promise<Result<MappedBuffer,
|
|
10
|
+
// RhiError>>` — success branch carries the branded handle the caller
|
|
11
|
+
// subsequently consumes for getMappedRange / unmap.
|
|
12
|
+
// - `MappedBuffer.getMappedRange(...)` / `MappedBuffer.unmap()` are method
|
|
13
|
+
// forms on the brand (`this: MappedBuffer` implicit guard); the previous
|
|
14
|
+
// top-level `Buffer.getMappedRange` / `Buffer.unmap` are removed.
|
|
15
|
+
//
|
|
16
|
+
// charter mapping: proposition 4 (TS compile-time guard against calling
|
|
17
|
+
// getMappedRange on an un-mapped Buffer) + proposition 5 (consistent
|
|
18
|
+
// abstraction — the brand encodes state at the type level, no runtime
|
|
19
|
+
// re-validation needed at every call site).
|
|
20
|
+
//
|
|
21
|
+
// Related Rust analogue: `wgpu::BufferSlice<'_>` ownership/borrow tracking;
|
|
22
|
+
// forgeax uses a brand instead of lifetimes because TS lacks affine types.
|
|
23
|
+
|
|
24
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
25
|
+
import type { Buffer, MappedBuffer, Result, RhiError } from '../index';
|
|
26
|
+
|
|
27
|
+
describe('MappedBuffer brand — type-level state machine', () => {
|
|
28
|
+
it('mapAsync resolves to Promise<Result<MappedBuffer, RhiError>>', () => {
|
|
29
|
+
type MapAsyncRet = ReturnType<Buffer['mapAsync']>;
|
|
30
|
+
expectTypeOf<MapAsyncRet>().toEqualTypeOf<Promise<Result<MappedBuffer, RhiError>>>();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('MappedBuffer is a structural superset of Buffer (Buffer & { __mapped })', () => {
|
|
34
|
+
expectTypeOf<MappedBuffer>().toMatchTypeOf<Buffer>();
|
|
35
|
+
// Negative direction — plain Buffer is NOT a MappedBuffer (brand guard).
|
|
36
|
+
// @ts-expect-error TS2345 — Buffer is missing the __mapped brand.
|
|
37
|
+
const _bogus: MappedBuffer = undefined as unknown as Buffer;
|
|
38
|
+
void _bogus;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('getMappedRange is a method on MappedBuffer (not on Buffer)', () => {
|
|
42
|
+
type GMR = MappedBuffer['getMappedRange'];
|
|
43
|
+
expectTypeOf<GMR>().toMatchTypeOf<
|
|
44
|
+
(offset?: number, size?: number) => Result<ArrayBuffer, RhiError>
|
|
45
|
+
>();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('unmap is a method on MappedBuffer (not on Buffer)', () => {
|
|
49
|
+
type Unmap = MappedBuffer['unmap'];
|
|
50
|
+
expectTypeOf<Unmap>().toMatchTypeOf<() => void>();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('AI user pattern: const r = await buf.mapAsync(...); if (r.ok) mappedBuffer.getMappedRange()', async () => {
|
|
54
|
+
const buffer = undefined as unknown as Buffer;
|
|
55
|
+
const r = await buffer.mapAsync(1);
|
|
56
|
+
if (r.ok) {
|
|
57
|
+
const mappedBuffer: MappedBuffer = r.value;
|
|
58
|
+
// method-form: mappedBuffer.getMappedRange and mappedBuffer.unmap exist
|
|
59
|
+
const range = mappedBuffer.getMappedRange();
|
|
60
|
+
expectTypeOf(range).toEqualTypeOf<Result<ArrayBuffer, RhiError>>();
|
|
61
|
+
const u = mappedBuffer.unmap();
|
|
62
|
+
expectTypeOf<typeof u>().toEqualTypeOf<void>();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|