@forgeax/engine-app 0.1.4 → 0.1.7
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/README.md +20 -14
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/create-app-error-retention.integration.test.d.ts +2 -0
- package/dist/__tests__/create-app-error-retention.integration.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-physics-plugin-failure.integration.test.d.ts +2 -0
- package/dist/__tests__/create-app-physics-plugin-failure.integration.test.d.ts.map +1 -0
- package/dist/animation-asset-lookup.d.ts +1 -2
- package/dist/animation-asset-lookup.d.ts.map +1 -1
- package/dist/browser-frame-signal.d.ts +17 -0
- package/dist/browser-frame-signal.d.ts.map +1 -0
- package/dist/create-app.d.ts +31 -10
- package/dist/create-app.d.ts.map +1 -1
- package/dist/engine-worker-runtime.mjs +72 -129
- package/dist/engine-worker-runtime.mjs.map +1 -1
- package/dist/errors.d.ts +3 -12
- package/dist/errors.d.ts.map +1 -1
- package/dist/execution/attached-world-swap.d.ts +1 -12
- package/dist/execution/attached-world-swap.d.ts.map +1 -1
- package/dist/execution/bootstrap-url.d.ts +5 -0
- package/dist/execution/bootstrap-url.d.ts.map +1 -0
- package/dist/execution/control.d.ts +0 -2
- package/dist/execution/control.d.ts.map +1 -1
- package/dist/execution/engine-worker.d.ts +1 -0
- package/dist/execution/engine-worker.d.ts.map +1 -1
- package/dist/execution/host-controller.d.ts.map +1 -1
- package/dist/execution/index.d.ts +1 -1
- package/dist/execution/index.d.ts.map +1 -1
- package/dist/execution/protocol.d.ts +4 -1
- package/dist/execution/protocol.d.ts.map +1 -1
- package/dist/execution/types.d.ts +17 -1
- package/dist/execution/types.d.ts.map +1 -1
- package/dist/game-context.d.ts +1 -1
- package/dist/game-context.d.ts.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +357 -359
- package/dist/index.mjs.map +1 -1
- package/dist/internal/assembled-engine-profile.d.ts.map +1 -1
- package/dist/internal/assets-world-plugin.d.ts +3 -17
- package/dist/internal/assets-world-plugin.d.ts.map +1 -1
- package/dist/internal/browser-remote-bridge.d.ts +1 -2
- package/dist/internal/browser-remote-bridge.d.ts.map +1 -1
- package/dist/internal/browser-rhi-debug-runtime.d.ts +1 -1
- package/dist/internal/browser-rhi-debug-runtime.d.ts.map +1 -1
- package/dist/internal/debug-draw.d.ts +9 -0
- package/dist/internal/debug-draw.d.ts.map +1 -0
- package/dist/internal/engine-profile-common.d.ts +2 -2
- package/dist/internal/engine-profile-common.d.ts.map +1 -1
- package/dist/internal/frame-loop.d.ts +7 -4
- package/dist/internal/frame-loop.d.ts.map +1 -1
- package/dist/internal/main-engine-profile.d.ts +4 -0
- package/dist/internal/main-engine-profile.d.ts.map +1 -1
- package/dist/internal/remote-serve-flag.d.ts +3 -7
- package/dist/internal/remote-serve-flag.d.ts.map +1 -1
- package/dist/internal/rhi-capture.d.ts +1 -1
- package/dist/internal/rhi-capture.d.ts.map +1 -1
- package/dist/internal/worker-engine-profile.d.ts +2 -0
- package/dist/internal/worker-engine-profile.d.ts.map +1 -1
- package/dist/renderer-plugin.d.ts +25 -0
- package/dist/renderer-plugin.d.ts.map +1 -0
- package/dist/tool-preview/bootstrap.d.ts.map +1 -1
- package/dist/types.d.ts +21 -61
- package/dist/types.d.ts.map +1 -1
- package/package.json +25 -26
- package/src/__tests__/callback-inference.test-d.ts +7 -3
- package/src/__tests__/consumer-migration.unit.test.ts +13 -0
- package/src/__tests__/create-app-error-retention.integration.test.ts +125 -0
- package/src/__tests__/create-app-features.test.ts +21 -10
- package/src/__tests__/create-app-listener-sync.test.ts +23 -19
- package/src/__tests__/create-app-physics-plugin-failure.integration.test.ts +142 -0
- package/src/__tests__/create-app-stop.test.ts +4 -12
- package/src/__tests__/create-app.test-d.ts +12 -11
- package/src/__tests__/create-app.test.ts +1 -60
- package/src/__tests__/create-renderer-error.integration.test.ts +3 -2
- package/src/__tests__/create-renderer-lifecycle.integration.test.ts +10 -5
- package/src/__tests__/draw-source-injected-world-update-visibility.test.ts +7 -13
- package/src/__tests__/draw-source-single-world-regression.test.ts +23 -24
- package/src/__tests__/engine-worker-bootstrap-channel.unit.test.ts +8 -0
- package/src/__tests__/engine-worker-world-swap.unit.test.ts +15 -22
- package/src/__tests__/errors-pointer-lock-failed.test.ts +4 -7
- package/src/__tests__/execution-bootstrap-isolation.unit.test.ts +5 -6
- package/src/__tests__/execution-frame-order.unit.test.ts +2 -2
- package/src/__tests__/execution-frame-protocol.unit.test.ts +3 -4
- package/src/__tests__/execution-public-api.test-d.ts +4 -0
- package/src/__tests__/frame-loop-time-elapsed.test.ts +1 -1
- package/src/__tests__/frame-loop-time.unit.test.ts +1 -1
- package/src/__tests__/frame-loop-world-array.test.ts +82 -73
- package/src/__tests__/plugin-profiles.unit.test.ts +3 -3
- package/src/__tests__/profiler-default-off.test.ts +1 -1
- package/src/__tests__/profiler-frame-token.test.ts +17 -30
- package/src/__tests__/profiler-phase-integration.test.ts +15 -27
- package/src/__tests__/surface-handoff.test.ts +1 -4
- package/src/__tests__/visibility-remote.integration.test.ts +2 -5
- package/src/__tests__/worker-execution-stop.test.ts +64 -7
- package/src/animation-asset-lookup.ts +2 -2
- package/src/browser-frame-signal.ts +44 -0
- package/src/create-app.ts +208 -295
- package/src/errors.ts +27 -39
- package/src/execution/attached-world-swap.ts +4 -38
- package/src/execution/bootstrap-url.ts +25 -0
- package/src/execution/control.ts +3 -7
- package/src/execution/engine-worker-runtime.ts +23 -40
- package/src/execution/engine-worker.ts +2 -0
- package/src/execution/host-controller.ts +8 -2
- package/src/execution/index.ts +1 -0
- package/src/execution/protocol.ts +4 -1
- package/src/execution/types.ts +18 -1
- package/src/game-context.ts +1 -1
- package/src/index.ts +18 -4
- package/src/internal/assembled-engine-profile.ts +3 -11
- package/src/internal/assets-world-plugin.ts +7 -53
- package/src/internal/browser-remote-bridge.ts +2 -3
- package/src/internal/browser-rhi-debug-runtime.ts +1 -1
- package/src/internal/debug-draw.ts +38 -0
- package/src/internal/engine-profile-common.ts +2 -3
- package/src/internal/frame-loop.ts +77 -63
- package/src/internal/main-engine-profile.ts +21 -17
- package/src/internal/remote-serve-flag.ts +5 -14
- package/src/internal/rhi-capture.ts +1 -1
- package/src/internal/worker-engine-profile.ts +10 -10
- package/src/renderer-plugin.ts +96 -0
- package/src/tool-preview/bootstrap.ts +1 -14
- package/src/types.ts +22 -77
- package/src/vite-env.d.ts +0 -2
- package/dist/__tests__/app-rebuild.test.d.ts +0 -2
- package/dist/__tests__/app-rebuild.test.d.ts.map +0 -1
- package/dist/__tests__/asset-registry-types.test-d.d.ts +0 -2
- package/dist/__tests__/asset-registry-types.test-d.d.ts.map +0 -1
- package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts +0 -2
- package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts.map +0 -1
- package/dist/__tests__/draw-source-headless-full-chain.test.d.ts +0 -2
- package/dist/__tests__/draw-source-headless-full-chain.test.d.ts.map +0 -1
- package/dist/__tests__/ecs-import.unit.test.d.ts +0 -2
- package/dist/__tests__/ecs-import.unit.test.d.ts.map +0 -1
- package/dist/__tests__/execution-world-health-owner.test-d.d.ts +0 -2
- package/dist/__tests__/execution-world-health-owner.test-d.d.ts.map +0 -1
- package/dist/__tests__/remote-serve-flag.test.d.ts +0 -2
- package/dist/__tests__/remote-serve-flag.test.d.ts.map +0 -1
- package/dist/assets-runtime-assembly.d.ts +0 -19
- package/dist/assets-runtime-assembly.d.ts.map +0 -1
- package/src/__tests__/app-rebuild.test.ts +0 -31
- package/src/__tests__/asset-registry-types.test-d.ts +0 -16
- package/src/__tests__/assets-runtime-assembly.integration.test.ts +0 -68
- package/src/__tests__/draw-source-headless-full-chain.test.ts +0 -226
- package/src/__tests__/ecs-import.unit.test.ts +0 -40
- package/src/__tests__/execution-world-health-owner.test-d.ts +0 -9
- package/src/__tests__/remote-serve-flag.test.ts +0 -24
- package/src/assets-runtime-assembly.ts +0 -70
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
1
3
|
import { Time, Update, World } from '@forgeax/engine-ecs';
|
|
2
4
|
import { describe, expect, it } from 'vitest';
|
|
3
5
|
|
|
@@ -18,4 +20,15 @@ describe('callback consumer migration', () => {
|
|
|
18
20
|
world.update(1 / 60).unwrap();
|
|
19
21
|
expect(observed).toBeCloseTo(1 / 60);
|
|
20
22
|
});
|
|
23
|
+
|
|
24
|
+
it('uses the receipt-bound renderer host contract', () => {
|
|
25
|
+
const source = [
|
|
26
|
+
readFileSync(resolve(process.cwd(), 'packages/app/src/create-app.ts'), 'utf8'),
|
|
27
|
+
readFileSync(resolve(process.cwd(), 'packages/app/src/internal/frame-loop.ts'), 'utf8'),
|
|
28
|
+
].join('\n');
|
|
29
|
+
expect(source).toContain('renderer.attach(');
|
|
30
|
+
expect(source).toContain('renderer.draw({');
|
|
31
|
+
expect(source).not.toContain('renderer.ready');
|
|
32
|
+
expect(source).not.toContain('renderer.device');
|
|
33
|
+
});
|
|
21
34
|
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Update, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { Renderer } from '@forgeax/engine-render';
|
|
3
|
+
import { RhiError } from '@forgeax/engine-rhi';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createApp } from '../create-app';
|
|
6
|
+
import type { AppDispatchError } from '../types';
|
|
7
|
+
|
|
8
|
+
function makeRenderer(): {
|
|
9
|
+
readonly renderer: Renderer;
|
|
10
|
+
readonly draw: ReturnType<typeof vi.fn>;
|
|
11
|
+
readonly emit: (error: RhiError) => void;
|
|
12
|
+
} {
|
|
13
|
+
const draw = vi.fn(() => ({ ok: true as const, value: undefined }));
|
|
14
|
+
let listener: ((event: { readonly kind: 'error'; readonly error: RhiError }) => void) | undefined;
|
|
15
|
+
const renderer = {
|
|
16
|
+
backend: 'webgpu' as const,
|
|
17
|
+
ready: Promise.resolve({ ok: true as const, value: undefined }),
|
|
18
|
+
draw,
|
|
19
|
+
attach: () => ({ ok: true as const, value: { dispose: () => undefined } }),
|
|
20
|
+
attachWorld: () => ({ ok: true as const, value: undefined }),
|
|
21
|
+
detachWorld: () => undefined,
|
|
22
|
+
subscribe: (next: (event: { readonly kind: 'error'; readonly error: RhiError }) => void) => {
|
|
23
|
+
listener = next;
|
|
24
|
+
return () => {
|
|
25
|
+
if (listener === next) listener = undefined;
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
onLost: () => () => undefined,
|
|
29
|
+
dispose: () => undefined,
|
|
30
|
+
} as unknown as Renderer;
|
|
31
|
+
return { renderer, draw, emit: (error) => listener?.({ kind: 'error', error }) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function installFaultingSystem(world: World, cause: Error): void {
|
|
35
|
+
world
|
|
36
|
+
.addSystem(Update, {
|
|
37
|
+
name: 'retention-faulting-update',
|
|
38
|
+
queries: [],
|
|
39
|
+
fn: () => {
|
|
40
|
+
throw cause;
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
.unwrap();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function createPausedApp(world: World, renderer: Renderer, silenceUnhandledErrors = true) {
|
|
47
|
+
const result = await createApp({ renderer, world, silenceUnhandledErrors });
|
|
48
|
+
expect(result.ok).toBe(true);
|
|
49
|
+
if (!result.ok) throw result.error;
|
|
50
|
+
result.value.start().unwrap();
|
|
51
|
+
result.value.pause().unwrap();
|
|
52
|
+
return result.value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('createApp error retention', () => {
|
|
56
|
+
it('retains a real frame/update system failure through the main dispatch owner', async () => {
|
|
57
|
+
const cause = new Error('boom');
|
|
58
|
+
const world = new World();
|
|
59
|
+
installFaultingSystem(world, cause);
|
|
60
|
+
const { renderer, draw } = makeRenderer();
|
|
61
|
+
const app = await createPausedApp(world, renderer);
|
|
62
|
+
let observed: AppDispatchError | undefined;
|
|
63
|
+
app.onError((error) => {
|
|
64
|
+
observed = error;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const frame = app.stepFrame(1 / 60);
|
|
68
|
+
|
|
69
|
+
expect(frame.ok).toBe(false);
|
|
70
|
+
if (frame.ok) return;
|
|
71
|
+
expect(frame.error.code).toBe('app-system-update-failed');
|
|
72
|
+
if (frame.error.code !== 'app-system-update-failed') return;
|
|
73
|
+
const updateError = frame.error.detail.cause as {
|
|
74
|
+
readonly code?: string;
|
|
75
|
+
readonly detail?: { readonly cause?: unknown };
|
|
76
|
+
};
|
|
77
|
+
expect(updateError.code).toBe('system-failed');
|
|
78
|
+
expect(updateError.detail?.cause).toBe(cause);
|
|
79
|
+
expect(observed).toBe(frame.error);
|
|
80
|
+
expect(app.lastError).toBe(frame.error);
|
|
81
|
+
expect(world.execution.health).toBe('poisoned');
|
|
82
|
+
expect(world.inspect().systems.map((system) => system.name)).toContain(
|
|
83
|
+
'retention-faulting-update',
|
|
84
|
+
);
|
|
85
|
+
expect(draw).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('retains the same structured failure when console fallback is the only observer', async () => {
|
|
89
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
90
|
+
const world = new World();
|
|
91
|
+
installFaultingSystem(world, new Error('unobserved boom'));
|
|
92
|
+
const { renderer } = makeRenderer();
|
|
93
|
+
const app = await createPausedApp(world, renderer, false);
|
|
94
|
+
|
|
95
|
+
const frame = app.stepFrame(1 / 60);
|
|
96
|
+
|
|
97
|
+
expect(frame.ok).toBe(false);
|
|
98
|
+
if (!frame.ok) {
|
|
99
|
+
expect(consoleError).toHaveBeenCalledTimes(1);
|
|
100
|
+
expect(consoleError).toHaveBeenCalledWith(frame.error);
|
|
101
|
+
expect(app.lastError).toBe(frame.error);
|
|
102
|
+
}
|
|
103
|
+
consoleError.mockRestore();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('preserves renderer device-loss identity and retention', async () => {
|
|
107
|
+
const world = new World();
|
|
108
|
+
const { renderer, emit } = makeRenderer();
|
|
109
|
+
const app = await createPausedApp(world, renderer);
|
|
110
|
+
let observed: AppDispatchError | undefined;
|
|
111
|
+
app.onError((error) => {
|
|
112
|
+
observed = error;
|
|
113
|
+
});
|
|
114
|
+
const deviceLoss = new RhiError({
|
|
115
|
+
code: 'device-lost',
|
|
116
|
+
expected: 'the test device remains available',
|
|
117
|
+
hint: 'rebuild the renderer after device recovery',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
emit(deviceLoss);
|
|
121
|
+
|
|
122
|
+
expect(observed).toBe(deviceLoss);
|
|
123
|
+
expect(app.lastError).toBe(deviceLoss);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
import type { RenderFeature } from '@forgeax/engine-render';
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const constructRuntimeRendererHost = vi.hoisted(() => vi.fn());
|
|
5
5
|
|
|
6
6
|
vi.mock('@forgeax/engine-runtime', async (importOriginal) => {
|
|
7
7
|
const actual = await importOriginal<typeof import('@forgeax/engine-runtime')>();
|
|
8
|
-
return
|
|
8
|
+
return actual;
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
+
vi.mock('@forgeax/engine-runtime/internal/renderer-host', () => ({
|
|
12
|
+
constructRuntimeRendererHost,
|
|
13
|
+
loadRhiPack: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
11
16
|
import { createApp } from '../create-app';
|
|
12
17
|
|
|
13
18
|
function rendererStub() {
|
|
19
|
+
const lease = {};
|
|
14
20
|
return {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
attach: () => ({ ok: true as const, value: lease }),
|
|
22
|
+
draw: () => ({ ok: true as const, value: {} }),
|
|
23
|
+
observe: async () => ({ ok: true as const, value: {} }),
|
|
24
|
+
releaseSurface: () => ({ ok: true as const, value: undefined }),
|
|
25
|
+
restoreSurface: () => ({ ok: true as const, value: undefined }),
|
|
26
|
+
recover: async () => ({ ok: true as const, value: undefined }),
|
|
18
27
|
onError: () => () => undefined,
|
|
19
28
|
onLost: () => () => undefined,
|
|
20
|
-
onHealthChange: () => () => undefined,
|
|
21
29
|
dispose: () => undefined,
|
|
22
30
|
};
|
|
23
31
|
}
|
|
@@ -27,14 +35,17 @@ describe('createApp features passthrough', () => {
|
|
|
27
35
|
const first = { identity: 'test.first' } as unknown as RenderFeature<unknown>;
|
|
28
36
|
const second = { identity: 'test.second' } as unknown as RenderFeature<unknown>;
|
|
29
37
|
const features = [first, second] as const;
|
|
30
|
-
|
|
38
|
+
constructRuntimeRendererHost.mockResolvedValue({
|
|
39
|
+
ok: true,
|
|
40
|
+
value: { renderer: rendererStub(), assets: undefined },
|
|
41
|
+
});
|
|
31
42
|
|
|
32
43
|
const canvas = { tagName: 'canvas', isConnected: true } as HTMLCanvasElement;
|
|
33
44
|
const result = await createApp(canvas, { features });
|
|
34
45
|
|
|
35
46
|
expect(result.ok).toBe(true);
|
|
36
|
-
expect(
|
|
37
|
-
expect(
|
|
38
|
-
expect(
|
|
47
|
+
expect(constructRuntimeRendererHost).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(constructRuntimeRendererHost.mock.calls[0]?.[1]).toMatchObject({ features });
|
|
49
|
+
expect(constructRuntimeRendererHost.mock.calls[0]?.[1].features).toBe(features);
|
|
39
50
|
});
|
|
40
51
|
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// test used the assemble form with a hand-copied registerListenerSyncSystem
|
|
7
7
|
// helper — a shadow copy of the production code that left w25 untested.
|
|
8
8
|
//
|
|
9
|
-
// Approach: vi.mock
|
|
9
|
+
// Approach: vi.mock constructRuntimeRendererHost (only construction overridden) so the canvas
|
|
10
10
|
// form does not attempt real WebGPU init; all other exports from
|
|
11
11
|
// @forgeax/engine-runtime remain real (Transform, registerPropagateTransforms,
|
|
12
12
|
// PROPAGATE_TRANSFORMS_SYSTEM, etc.). globalThis.AudioContext is stubbed
|
|
@@ -34,43 +34,47 @@ import { Transform } from '@forgeax/engine-scene';
|
|
|
34
34
|
import { describe, expect, it, vi } from 'vitest';
|
|
35
35
|
import { createApp } from '../create-app';
|
|
36
36
|
|
|
37
|
+
const constructRuntimeRendererHost = vi.hoisted(() => vi.fn());
|
|
38
|
+
|
|
37
39
|
// ---------------------------------------------------------------------------
|
|
38
|
-
// Mock
|
|
40
|
+
// Mock constructRuntimeRendererHost so the canvas form does not attempt real WebGPU init.
|
|
39
41
|
// All other @forgeax/engine-runtime exports stay real (Transform,
|
|
40
42
|
// registerPropagateTransforms, advanceAnimationPlayer, etc.).
|
|
41
43
|
// ---------------------------------------------------------------------------
|
|
42
44
|
|
|
43
45
|
vi.mock('@forgeax/engine-runtime', async (importOriginal) => {
|
|
44
46
|
const actual = await importOriginal<typeof import('@forgeax/engine-runtime')>();
|
|
45
|
-
const
|
|
46
|
-
ok: true,
|
|
47
|
-
value: undefined,
|
|
48
|
-
});
|
|
47
|
+
const lease = { dispose: vi.fn() };
|
|
49
48
|
const rendererStub = {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
draw(): { ok: true; value: undefined } {
|
|
53
|
-
return { ok: true, value: undefined };
|
|
54
|
-
},
|
|
55
|
-
attachWorld(): { ok: true; value: undefined } {
|
|
56
|
-
return { ok: true, value: undefined };
|
|
49
|
+
attach(): { ok: true; value: object } {
|
|
50
|
+
return { ok: true, value: lease };
|
|
57
51
|
},
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return () => {};
|
|
52
|
+
draw(): { ok: true; value: object } {
|
|
53
|
+
return { ok: true, value: {} };
|
|
61
54
|
},
|
|
62
|
-
|
|
55
|
+
observe: async () => ({ ok: true as const, value: {} }),
|
|
56
|
+
releaseSurface: () => ({ ok: true as const, value: undefined }),
|
|
57
|
+
restoreSurface: () => ({ ok: true as const, value: undefined }),
|
|
58
|
+
recover: async () => ({ ok: true as const, value: undefined }),
|
|
59
|
+
subscribe(): () => void {
|
|
63
60
|
return () => {};
|
|
64
61
|
},
|
|
65
62
|
dispose: vi.fn(),
|
|
66
|
-
assets: null,
|
|
67
63
|
};
|
|
64
|
+
constructRuntimeRendererHost.mockResolvedValue({
|
|
65
|
+
ok: true,
|
|
66
|
+
value: { renderer: rendererStub, assets: undefined },
|
|
67
|
+
});
|
|
68
68
|
return {
|
|
69
69
|
...actual,
|
|
70
|
-
createRenderer: vi.fn().mockResolvedValue(rendererStub),
|
|
71
70
|
};
|
|
72
71
|
});
|
|
73
72
|
|
|
73
|
+
vi.mock('@forgeax/engine-runtime/internal/renderer-host', () => ({
|
|
74
|
+
constructRuntimeRendererHost,
|
|
75
|
+
loadRhiPack: vi.fn(),
|
|
76
|
+
}));
|
|
77
|
+
|
|
74
78
|
// ---------------------------------------------------------------------------
|
|
75
79
|
// Test helpers
|
|
76
80
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { PhysicsError, physicsPlugin } from '@forgeax/engine-physics';
|
|
3
|
+
import type { Renderer } from '@forgeax/engine-render';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createApp } from '../create-app';
|
|
6
|
+
|
|
7
|
+
const rapier3d = vi.hoisted(() => ({
|
|
8
|
+
load: vi.fn(),
|
|
9
|
+
createWorld: vi.fn(),
|
|
10
|
+
registerSystems: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const rapier2d = vi.hoisted(() => ({
|
|
14
|
+
load: vi.fn(),
|
|
15
|
+
createWorld: vi.fn(),
|
|
16
|
+
registerSystems: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('@forgeax/engine-physics-rapier3d', () => ({
|
|
20
|
+
loadRapier3D: rapier3d.load,
|
|
21
|
+
createRapier3DPhysicsWorld: rapier3d.createWorld,
|
|
22
|
+
registerPhysicsSystems: rapier3d.registerSystems,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('@forgeax/engine-physics-rapier2d', () => ({
|
|
26
|
+
loadRapier2D: rapier2d.load,
|
|
27
|
+
createRapier2DPhysicsWorld: rapier2d.createWorld,
|
|
28
|
+
registerPhysicsSystems2D: rapier2d.registerSystems,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
function rendererStub(): Renderer {
|
|
32
|
+
return {
|
|
33
|
+
backend: 'webgpu',
|
|
34
|
+
ready: Promise.resolve({ ok: true, value: undefined }),
|
|
35
|
+
draw: () => ({ ok: true, value: undefined }),
|
|
36
|
+
onError: () => () => {},
|
|
37
|
+
onLost: () => () => {},
|
|
38
|
+
assets: {},
|
|
39
|
+
dispose: () => {},
|
|
40
|
+
} as unknown as Renderer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function injectedWasmFailure(backend: '2D' | '3D', reason: string): PhysicsError {
|
|
44
|
+
return new PhysicsError({
|
|
45
|
+
code: 'wasm-load-failed',
|
|
46
|
+
expected: `the injected Rapier ${backend} loader to become ready`,
|
|
47
|
+
hint: `injected failure: ${reason}`,
|
|
48
|
+
detail: { code: 'wasm-load-failed', reason },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('createApp Rapier 3D activation failure', () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
rapier3d.load.mockReset();
|
|
55
|
+
rapier3d.createWorld.mockReset();
|
|
56
|
+
rapier3d.registerSystems.mockReset();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it.each([
|
|
60
|
+
{
|
|
61
|
+
phase: 'loader return',
|
|
62
|
+
inject: () => rapier3d.load.mockResolvedValue(injectedWasmFailure('3D', 'loader return')),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
phase: 'compat import',
|
|
66
|
+
inject: () => rapier3d.load.mockRejectedValue(new Error('injected compat import rejection')),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
phase: 'WASM init',
|
|
70
|
+
inject: () => rapier3d.load.mockRejectedValue(new Error('injected WASM init rejection')),
|
|
71
|
+
},
|
|
72
|
+
])('normalizes $phase before PhysicsWorld creation', async ({ inject }) => {
|
|
73
|
+
inject();
|
|
74
|
+
const world = new World();
|
|
75
|
+
|
|
76
|
+
const result = await createApp({
|
|
77
|
+
renderer: rendererStub(),
|
|
78
|
+
world,
|
|
79
|
+
plugins: [physicsPlugin('rapier-3d')],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result.ok).toBe(false);
|
|
83
|
+
if (result.ok) return;
|
|
84
|
+
expect(result.error.code).toBe('app-plugin-activation-failed');
|
|
85
|
+
if (result.error.code !== 'app-plugin-activation-failed') return;
|
|
86
|
+
expect(result.error.detail.cause).toMatchObject({
|
|
87
|
+
name: 'PhysicsError',
|
|
88
|
+
code: 'wasm-load-failed',
|
|
89
|
+
detail: { code: 'wasm-load-failed' },
|
|
90
|
+
});
|
|
91
|
+
expect(rapier3d.createWorld).not.toHaveBeenCalled();
|
|
92
|
+
expect(rapier3d.registerSystems).not.toHaveBeenCalled();
|
|
93
|
+
expect(world.hasResource('PhysicsWorld')).toBe(false);
|
|
94
|
+
expect(world.update(0)).toMatchObject({ ok: true });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('createApp Rapier 2D activation failure', () => {
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
rapier2d.load.mockReset();
|
|
101
|
+
rapier2d.createWorld.mockReset();
|
|
102
|
+
rapier2d.registerSystems.mockReset();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
{
|
|
107
|
+
phase: 'loader return',
|
|
108
|
+
inject: () => rapier2d.load.mockResolvedValue(injectedWasmFailure('2D', 'loader return')),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
phase: 'compat import',
|
|
112
|
+
inject: () => rapier2d.load.mockRejectedValue(new Error('injected compat import rejection')),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
phase: 'WASM init',
|
|
116
|
+
inject: () => rapier2d.load.mockRejectedValue(new Error('injected WASM init rejection')),
|
|
117
|
+
},
|
|
118
|
+
])('normalizes $phase before PhysicsWorld creation', async ({ inject }) => {
|
|
119
|
+
inject();
|
|
120
|
+
const world = new World();
|
|
121
|
+
|
|
122
|
+
const result = await createApp({
|
|
123
|
+
renderer: rendererStub(),
|
|
124
|
+
world,
|
|
125
|
+
plugins: [physicsPlugin('rapier-2d')],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result.ok).toBe(false);
|
|
129
|
+
if (result.ok) return;
|
|
130
|
+
expect(result.error.code).toBe('app-plugin-activation-failed');
|
|
131
|
+
if (result.error.code !== 'app-plugin-activation-failed') return;
|
|
132
|
+
expect(result.error.detail.cause).toMatchObject({
|
|
133
|
+
name: 'PhysicsError',
|
|
134
|
+
code: 'wasm-load-failed',
|
|
135
|
+
detail: { code: 'wasm-load-failed' },
|
|
136
|
+
});
|
|
137
|
+
expect(rapier2d.createWorld).not.toHaveBeenCalled();
|
|
138
|
+
expect(rapier2d.registerSystems).not.toHaveBeenCalled();
|
|
139
|
+
expect(world.hasResource('PhysicsWorld')).toBe(false);
|
|
140
|
+
expect(world.update(0)).toMatchObject({ ok: true });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -15,6 +15,7 @@ import type { Renderer } from '@forgeax/engine-render';
|
|
|
15
15
|
import type { RhiError } from '@forgeax/engine-rhi/errors';
|
|
16
16
|
import { ok } from '@forgeax/engine-types';
|
|
17
17
|
import { describe, expect, it, vi } from 'vitest';
|
|
18
|
+
import { RendererContractFailureError } from '../../../render/src/errors/render';
|
|
18
19
|
|
|
19
20
|
import { createApp } from '../create-app';
|
|
20
21
|
|
|
@@ -38,16 +39,11 @@ function makeRendererStubForStop(): StubRenderer {
|
|
|
38
39
|
draw(): { ok: true; value: undefined } {
|
|
39
40
|
return { ok: true, value: undefined };
|
|
40
41
|
},
|
|
41
|
-
|
|
42
|
+
attach(): { ok: true; value: undefined } {
|
|
42
43
|
return { ok: true, value: undefined };
|
|
43
44
|
},
|
|
44
45
|
detachWorld(): void {},
|
|
45
|
-
|
|
46
|
-
return () => {
|
|
47
|
-
// no-op unsubscribe
|
|
48
|
-
};
|
|
49
|
-
},
|
|
50
|
-
onLost(): () => void {
|
|
46
|
+
subscribe(): () => void {
|
|
51
47
|
return () => {
|
|
52
48
|
// no-op unsubscribe
|
|
53
49
|
};
|
|
@@ -83,11 +79,7 @@ describe('create-app-stop.test.ts', () => {
|
|
|
83
79
|
|
|
84
80
|
it('does not restore a running loop when surface release fails', async () => {
|
|
85
81
|
const { renderer } = makeRendererStubForStop();
|
|
86
|
-
const releaseError = new (
|
|
87
|
-
code: 'rhi-not-available',
|
|
88
|
-
expected: 'test release succeeds',
|
|
89
|
-
hint: 'forced failure',
|
|
90
|
-
});
|
|
82
|
+
const releaseError = new RendererContractFailureError('draw', 'forced failure');
|
|
91
83
|
vi.mocked(renderer.releaseSurface).mockReturnValue({ ok: false, error: releaseError });
|
|
92
84
|
const result = await createApp({ renderer, world: new World() });
|
|
93
85
|
expect(result.ok).toBe(true);
|
|
@@ -18,21 +18,19 @@
|
|
|
18
18
|
// - P4 consistent abstraction: both routes return the same App handle.
|
|
19
19
|
|
|
20
20
|
import type { World } from '@forgeax/engine-ecs';
|
|
21
|
-
import type { Renderer, RendererOptions } from '@forgeax/engine-render';
|
|
21
|
+
import type { RenderError, Renderer, RendererOptions } from '@forgeax/engine-render';
|
|
22
22
|
import type { RhiError } from '@forgeax/engine-rhi/errors';
|
|
23
|
-
import type
|
|
23
|
+
import { createRenderer, type EngineEnvironmentError } from '@forgeax/engine-runtime';
|
|
24
24
|
import type { Result } from '@forgeax/engine-types';
|
|
25
25
|
import { describe, expectTypeOf, it } from 'vitest';
|
|
26
|
-
import type { AssetRuntimeAssemblyError } from '../assets-runtime-assembly';
|
|
27
26
|
import { type App, type AppError, createApp } from '../index';
|
|
28
|
-
import type { RhiCaptureHook } from '../types';
|
|
29
27
|
|
|
30
28
|
describe('createApp double-SSOT entry signatures (w2 acceptanceCheck)', () => {
|
|
31
29
|
it('canvas form returns the App boundary error union', () => {
|
|
32
30
|
const canvas = null as unknown as HTMLCanvasElement;
|
|
33
31
|
const ret = createApp(canvas);
|
|
34
32
|
expectTypeOf(ret).toEqualTypeOf<
|
|
35
|
-
Promise<Result<App, AppError | RhiError |
|
|
33
|
+
Promise<Result<App, AppError | RhiError | EngineEnvironmentError>>
|
|
36
34
|
>();
|
|
37
35
|
});
|
|
38
36
|
|
|
@@ -40,9 +38,7 @@ describe('createApp double-SSOT entry signatures (w2 acceptanceCheck)', () => {
|
|
|
40
38
|
const renderer = null as unknown as Renderer;
|
|
41
39
|
const world = null as unknown as World;
|
|
42
40
|
const ret = createApp({ renderer, world });
|
|
43
|
-
expectTypeOf(ret).toEqualTypeOf<
|
|
44
|
-
Promise<Result<App, AppError | RhiError | AssetRuntimeAssemblyError>>
|
|
45
|
-
>();
|
|
41
|
+
expectTypeOf(ret).toEqualTypeOf<Promise<Result<App, AppError | RhiError>>>();
|
|
46
42
|
});
|
|
47
43
|
|
|
48
44
|
it('app.renderer / app.world are reference-equal types at the call site (AC-09)', async () => {
|
|
@@ -54,10 +50,15 @@ describe('createApp double-SSOT entry signatures (w2 acceptanceCheck)', () => {
|
|
|
54
50
|
expectTypeOf(result.value.world).toEqualTypeOf<World>();
|
|
55
51
|
}
|
|
56
52
|
});
|
|
53
|
+
});
|
|
57
54
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
describe('Runtime createRenderer Result boundary (AC-11)', () => {
|
|
56
|
+
it('requires callers to narrow the construction Result', () => {
|
|
57
|
+
const canvas = null as unknown as HTMLCanvasElement;
|
|
58
|
+
const expected = null as unknown as Promise<
|
|
59
|
+
Result<Renderer, EngineEnvironmentError | RenderError>
|
|
60
|
+
>;
|
|
61
|
+
expectTypeOf(createRenderer(canvas)).toEqualTypeOf(expected);
|
|
61
62
|
});
|
|
62
63
|
});
|
|
63
64
|
|
|
@@ -25,8 +25,7 @@ import { World } from '@forgeax/engine-ecs';
|
|
|
25
25
|
import { Camera, orthographic, perspective } from '@forgeax/engine-render';
|
|
26
26
|
import { describe, expect, it } from 'vitest';
|
|
27
27
|
|
|
28
|
-
import {
|
|
29
|
-
import { AppError } from '../errors';
|
|
28
|
+
import { syncCameraAspect, syncCanvasDrawingBuffer } from '../create-app';
|
|
30
29
|
import { resolveRhiDebugFlag } from '../internal/rhi-debug-flag';
|
|
31
30
|
|
|
32
31
|
describe('create-app.test.ts', () => {
|
|
@@ -119,64 +118,6 @@ describe('create-app.test.ts', () => {
|
|
|
119
118
|
}
|
|
120
119
|
});
|
|
121
120
|
|
|
122
|
-
it('applies a Host pixel-ratio cap without changing the default path', () => {
|
|
123
|
-
const canvas = {
|
|
124
|
-
clientWidth: 800,
|
|
125
|
-
clientHeight: 400,
|
|
126
|
-
width: 300,
|
|
127
|
-
height: 150,
|
|
128
|
-
style: { width: '800px', height: '400px' },
|
|
129
|
-
};
|
|
130
|
-
const before = globalThis.devicePixelRatio;
|
|
131
|
-
Object.defineProperty(globalThis, 'devicePixelRatio', { configurable: true, value: 2 });
|
|
132
|
-
try {
|
|
133
|
-
syncCanvasDrawingBuffer(canvas, 1.5);
|
|
134
|
-
expect(canvas.width).toBe(1200);
|
|
135
|
-
expect(canvas.height).toBe(600);
|
|
136
|
-
} finally {
|
|
137
|
-
Object.defineProperty(globalThis, 'devicePixelRatio', {
|
|
138
|
-
configurable: true,
|
|
139
|
-
value: before,
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('allows the Host to downsample a GPU-bound canvas below CSS pixel density', () => {
|
|
145
|
-
const canvas = {
|
|
146
|
-
clientWidth: 800,
|
|
147
|
-
clientHeight: 400,
|
|
148
|
-
width: 300,
|
|
149
|
-
height: 150,
|
|
150
|
-
style: { width: '800px', height: '400px' },
|
|
151
|
-
};
|
|
152
|
-
const before = globalThis.devicePixelRatio;
|
|
153
|
-
Object.defineProperty(globalThis, 'devicePixelRatio', { configurable: true, value: 2 });
|
|
154
|
-
try {
|
|
155
|
-
syncCanvasDrawingBuffer(canvas, 0.875);
|
|
156
|
-
expect(canvas.width).toBe(700);
|
|
157
|
-
expect(canvas.height).toBe(350);
|
|
158
|
-
} finally {
|
|
159
|
-
Object.defineProperty(globalThis, 'devicePixelRatio', {
|
|
160
|
-
configurable: true,
|
|
161
|
-
value: before,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('rejects an invalid Host pixel-ratio cap before touching a detached canvas', async () => {
|
|
167
|
-
const result = await createApp(
|
|
168
|
-
{ tagName: 'CANVAS', isConnected: false } as unknown as HTMLCanvasElement,
|
|
169
|
-
{ maxCanvasPixelRatio: 0 },
|
|
170
|
-
);
|
|
171
|
-
expect(result.ok).toBe(false);
|
|
172
|
-
if (!result.ok && result.error instanceof AppError) {
|
|
173
|
-
expect(result.error.code).toBe('app-invalid-canvas-pixel-ratio');
|
|
174
|
-
if (result.error.code === 'app-invalid-canvas-pixel-ratio') {
|
|
175
|
-
expect(result.error.detail.value).toBe(0);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
|
|
180
121
|
it('leaves a zero-sized host untouched', () => {
|
|
181
122
|
const canvas = { clientWidth: 0, clientHeight: 400, width: 300, height: 150 };
|
|
182
123
|
syncCanvasDrawingBuffer(canvas);
|
|
@@ -3,8 +3,9 @@ import { describe, expect, it } from 'vitest';
|
|
|
3
3
|
import { createApp } from '../create-app';
|
|
4
4
|
|
|
5
5
|
describe('app renderer construction boundary', () => {
|
|
6
|
-
it('
|
|
7
|
-
await
|
|
6
|
+
it('returns construction failures through the Runtime Result boundary', async () => {
|
|
7
|
+
const result = await createRenderer(null as never);
|
|
8
|
+
expect(result.ok).toBe(false);
|
|
8
9
|
});
|
|
9
10
|
|
|
10
11
|
it('returns EngineEnvironmentError through the canvas Result path', async () => {
|
|
@@ -5,13 +5,18 @@ describe('app-facing renderer lifecycle', () => {
|
|
|
5
5
|
it('consumes the three lifecycle Result boundaries without casts', async () => {
|
|
6
6
|
const events: string[] = [];
|
|
7
7
|
const renderer = {
|
|
8
|
-
|
|
9
|
-
draw: () => ({ ok:
|
|
8
|
+
attach: () => ({ ok: false as const, error: {} }),
|
|
9
|
+
draw: () => ({ ok: false as const, error: {} }),
|
|
10
|
+
observe: async () => ({ ok: false as const, error: {} }),
|
|
11
|
+
releaseSurface: () => ({ ok: true as const, value: undefined }),
|
|
12
|
+
restoreSurface: () => ({ ok: true as const, value: undefined }),
|
|
13
|
+
recover: async () => ({ ok: true as const, value: undefined }),
|
|
10
14
|
dispose: () => events.push('dispose'),
|
|
15
|
+
onError: () => () => undefined,
|
|
16
|
+
onLost: () => () => undefined,
|
|
11
17
|
} as unknown as Renderer;
|
|
12
|
-
|
|
13
|
-
expect(
|
|
14
|
-
expect(renderer.draw([], { cameraOwner: 0, resourceOwner: 0 }).ok).toBe(true);
|
|
18
|
+
expect(renderer.attach({} as never).ok).toBe(false);
|
|
19
|
+
expect(renderer.draw({} as never).ok).toBe(false);
|
|
15
20
|
renderer.dispose();
|
|
16
21
|
renderer.dispose();
|
|
17
22
|
expect(events).toEqual(['dispose', 'dispose']);
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
36
36
|
import { World } from '@forgeax/engine-ecs';
|
|
37
|
+
import { createRenderReadLease } from '@forgeax/engine-ecs/projection';
|
|
37
38
|
import type { Renderer } from '@forgeax/engine-render';
|
|
38
39
|
import { registerPropagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
39
40
|
import { describe, expect, it } from 'vitest';
|
|
@@ -123,19 +124,12 @@ describe('drawSource injected worlds are updated same-frame (w9, stale-matrix re
|
|
|
123
124
|
// stage performs (Transform.world is the extract SSOT).
|
|
124
125
|
const observed: Array<[number, number, number]> = [];
|
|
125
126
|
const renderer = {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const iw = worlds.find((w) => w === injectedWorld);
|
|
133
|
-
if (iw !== undefined) {
|
|
134
|
-
const r = iw.get(entity, Transform);
|
|
135
|
-
if (r.ok) {
|
|
136
|
-
const m = r.value.world as unknown as ArrayLike<number>;
|
|
137
|
-
observed.push([m[12] as number, m[13] as number, m[14] as number]);
|
|
138
|
-
}
|
|
127
|
+
attach: (world: World) => ({ ok: true, value: createRenderReadLease(world) }),
|
|
128
|
+
draw(): { ok: true; value: undefined } {
|
|
129
|
+
const r = injectedWorld.get(entity, Transform);
|
|
130
|
+
if (r.ok) {
|
|
131
|
+
const m = r.value.world as unknown as ArrayLike<number>;
|
|
132
|
+
observed.push([m[12] as number, m[13] as number, m[14] as number]);
|
|
139
133
|
}
|
|
140
134
|
return { ok: true, value: undefined };
|
|
141
135
|
},
|