Files
webeditor3d/tests/unit/runner-canvas.test.tsx
Victor Giers 9e224a4740 auto-git:
[change] src/runtime-three/runtime-host.ts
 [change] tests/unit/runner-canvas.test.tsx
2026-04-25 03:57:55 +02:00

588 lines
18 KiB
TypeScript

import { render, screen, waitFor } from "@testing-library/react";
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createDefaultSceneLoadingScreenSettings,
createEmptySceneDocument
} from "../../src/document/scene-document";
import { RunnerCanvas } from "../../src/runner-web/RunnerCanvas";
import type { FirstPersonTelemetry } from "../../src/runtime-three/navigation-controller";
import type { RuntimeSceneLoadState } from "../../src/runtime-three/runtime-host";
import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build";
function createSwimmingTelemetry(
runtimeScene: ReturnType<typeof buildRuntimeSceneFromDocument>,
cameraSubmerged: boolean
): FirstPersonTelemetry {
const signals = {
jumpStarted: false,
leftGround: false,
startedFalling: false,
landed: false,
enteredWater: false,
exitedWater: false,
wallContactStarted: false,
headBump: false
};
return {
feetPosition: { x: 0, y: 0, z: 0 },
eyePosition: { x: 0, y: cameraSubmerged ? 0.4 : 1.7, z: 0 },
yawDegrees: 0,
grounded: false,
locomotionState: {
locomotionMode: "swimming",
airborneKind: null,
gait: "walk",
grounded: false,
crouched: false,
sprinting: false,
inputMagnitude: 1,
requestedPlanarSpeed: runtimeScene.playerMovement.moveSpeed,
planarSpeed: runtimeScene.playerMovement.moveSpeed,
verticalVelocity: 0,
contact: {
collisionCount: 0,
collidedAxes: {
x: false,
y: false,
z: false
},
groundNormal: null,
groundDistance: null,
slopeDegrees: null
}
},
movement: runtimeScene.playerMovement,
inWaterVolume: true,
cameraSubmerged,
inFogVolume: false,
pointerLocked: true,
spawn: runtimeScene.spawn,
signals,
hooks: {
camera: {
jumping: false,
falling: false,
landing: false,
swimming: true,
underwaterAmount: cameraSubmerged ? 1 : 0.55
},
audio: {
underwaterAmount: cameraSubmerged ? 1 : 0.55,
enteredWater: false,
exitedWater: false
},
animation: {
locomotionMode: "swimming",
airborneKind: null,
gait: "walk",
moving: true,
movementAmount: 1,
grounded: false,
crouched: false,
sprinting: false,
inWater: true,
signals
}
}
};
}
const { MockRuntimeHost, runtimeHostInstances } = vi.hoisted(() => {
const runtimeHostInstances: Array<{
mount: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
loadScene: ReturnType<typeof vi.fn>;
updateAssets: ReturnType<typeof vi.fn>;
setNavigationMode: ReturnType<typeof vi.fn>;
setRuntimeClockStateHandler: ReturnType<typeof vi.fn>;
setRuntimeMessageHandler: ReturnType<typeof vi.fn>;
setFirstPersonTelemetryHandler: ReturnType<typeof vi.fn>;
setInteractionPromptHandler: ReturnType<typeof vi.fn>;
setRuntimeDialogueHandler: ReturnType<typeof vi.fn>;
setRuntimePauseStateHandler: ReturnType<typeof vi.fn>;
setSceneLoadStateHandler: ReturnType<typeof vi.fn>;
setSceneTransitionHandler: ReturnType<typeof vi.fn>;
}> = [];
class MockRuntimeHost {
mount = vi.fn();
dispose = vi.fn();
loadScene = vi.fn();
updateAssets = vi.fn();
setNavigationMode = vi.fn();
setRuntimeClockStateHandler = vi.fn();
setRuntimeMessageHandler = vi.fn();
setFirstPersonTelemetryHandler = vi.fn();
setInteractionPromptHandler = vi.fn();
setRuntimeDialogueHandler = vi.fn();
setRuntimePauseStateHandler = vi.fn();
setSceneLoadStateHandler = vi.fn();
setSceneTransitionHandler = vi.fn();
constructor() {
runtimeHostInstances.push(this);
}
}
return {
MockRuntimeHost,
runtimeHostInstances
};
});
vi.mock("../../src/runtime-three/runtime-host", () => ({
RuntimeHost: MockRuntimeHost
}));
describe("RunnerCanvas", () => {
beforeEach(() => {
runtimeHostInstances.length = 0;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("only shows the underwater overlay when the camera is submerged", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
const onTelemetryChange = vi.fn();
render(
<RunnerCanvas
runtimeScene={runtimeScene}
sceneName="Underwater Test"
sceneLoadingScreen={createDefaultSceneLoadingScreenSettings()}
projectAssets={{}}
loadedModelAssets={{}}
loadedImageAssets={{}}
loadedAudioAssets={{}}
navigationMode="firstPerson"
onRuntimeMessageChange={vi.fn()}
onFirstPersonTelemetryChange={onTelemetryChange}
onInteractionPromptChange={vi.fn()}
onSceneTransitionActivated={vi.fn()}
/>
);
await waitFor(() => {
expect(runtimeHostInstances).toHaveLength(1);
expect(
runtimeHostInstances[0]?.setFirstPersonTelemetryHandler
).toHaveBeenCalledTimes(1);
expect(
runtimeHostInstances[0]?.setSceneLoadStateHandler
).toHaveBeenCalledTimes(1);
});
const publishTelemetry = runtimeHostInstances[0]
?.setFirstPersonTelemetryHandler.mock.calls[0]?.[0] as
| ((telemetry: FirstPersonTelemetry | null) => void)
| undefined;
const publishSceneLoadState = runtimeHostInstances[0]
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void)
| undefined;
expect(publishTelemetry).toBeDefined();
expect(publishSceneLoadState).toBeDefined();
act(() => {
publishSceneLoadState?.({
status: "ready",
message: null
});
});
act(() => {
publishTelemetry?.(createSwimmingTelemetry(runtimeScene, false));
});
expect(
screen.queryByLabelText("Built-in scene runner")?.className
).not.toContain("runner-canvas--underwater");
expect(document.querySelector(".runner-canvas__underwater")).toBeNull();
act(() => {
publishTelemetry?.(createSwimmingTelemetry(runtimeScene, true));
});
expect(screen.getByLabelText("Built-in scene runner").className).toContain(
"runner-canvas--underwater"
);
expect(document.querySelector(".runner-canvas__underwater")).not.toBeNull();
});
it("shows the loading overlay until the runtime host reports readiness", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
render(
<RunnerCanvas
runtimeScene={runtimeScene}
sceneName="Dungeon Entry"
sceneLoadingScreen={{
colorHex: "#223344",
headline: "Preparing encounter",
description: "Enemies and triggers are being wired up."
}}
projectAssets={{}}
loadedModelAssets={{}}
loadedImageAssets={{}}
loadedAudioAssets={{}}
navigationMode="firstPerson"
onRuntimeMessageChange={vi.fn()}
onFirstPersonTelemetryChange={vi.fn()}
onInteractionPromptChange={vi.fn()}
onSceneTransitionActivated={vi.fn()}
/>
);
await waitFor(() => {
expect(runtimeHostInstances).toHaveLength(1);
expect(
runtimeHostInstances[0]?.setSceneLoadStateHandler
).toHaveBeenCalledTimes(1);
});
expect(
screen.getByTestId("runner-loading-overlay").className
).not.toContain("runner-canvas__loading-overlay--hidden");
expect(screen.getByTestId("runner-loading-scene-name")).toHaveTextContent(
"Dungeon Entry"
);
expect(screen.getByTestId("runner-loading-headline")).toHaveTextContent(
"Preparing encounter"
);
expect(screen.getByTestId("runner-loading-description")).toHaveTextContent(
"Enemies and triggers are being wired up."
);
expect(document.querySelector(".runner-canvas__crosshair")).toBeNull();
expect(screen.getByTestId("runner-shell")).toHaveAttribute(
"aria-busy",
"true"
);
const publishSceneLoadState = runtimeHostInstances[0]
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void)
| undefined;
act(() => {
publishSceneLoadState?.({
status: "ready",
message: null
});
});
await waitFor(() => {
expect(screen.getByTestId("runner-loading-overlay").className).toContain(
"runner-canvas__loading-overlay--hidden"
);
});
expect(document.querySelector(".runner-canvas__crosshair")).not.toBeNull();
expect(screen.getByTestId("runner-shell")).toHaveAttribute(
"aria-busy",
"false"
);
});
it("shows a centered pause overlay and hides the crosshair while paused", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
render(
<RunnerCanvas
runtimeScene={runtimeScene}
sceneName="Pause Runner"
sceneLoadingScreen={createDefaultSceneLoadingScreenSettings()}
projectAssets={{}}
loadedModelAssets={{}}
loadedImageAssets={{}}
loadedAudioAssets={{}}
navigationMode="firstPerson"
onRuntimeMessageChange={vi.fn()}
onFirstPersonTelemetryChange={vi.fn()}
onInteractionPromptChange={vi.fn()}
onSceneTransitionActivated={vi.fn()}
/>
);
await waitFor(() => {
expect(runtimeHostInstances).toHaveLength(1);
expect(
runtimeHostInstances[0]?.setSceneLoadStateHandler
).toHaveBeenCalledTimes(1);
expect(
runtimeHostInstances[0]?.setRuntimePauseStateHandler
).toHaveBeenCalledTimes(1);
});
const publishSceneLoadState = runtimeHostInstances[0]
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void)
| undefined;
const publishPauseState = runtimeHostInstances[0]
?.setRuntimePauseStateHandler.mock.calls[0]?.[0] as
| ((state: { paused: boolean; source: "manual" | "control" | "mixed" | null }) => void)
| undefined;
act(() => {
publishSceneLoadState?.({
status: "ready",
message: null
});
});
await waitFor(() => {
expect(document.querySelector(".runner-canvas__crosshair")).not.toBeNull();
});
act(() => {
publishPauseState?.({
paused: true,
source: "manual"
});
});
expect(screen.getByTestId("runner-pause-overlay")).toHaveTextContent(
"Pause"
);
expect(screen.getByTestId("runner-shell").className).toContain(
"runner-canvas--paused"
);
expect(document.querySelector(".runner-canvas__crosshair")).toBeNull();
});
it("keeps the overlay visible and shows load errors from the runtime host", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
render(
<RunnerCanvas
runtimeScene={runtimeScene}
sceneName="Broken Scene"
sceneLoadingScreen={createDefaultSceneLoadingScreenSettings()}
projectAssets={{}}
loadedModelAssets={{}}
loadedImageAssets={{}}
loadedAudioAssets={{}}
navigationMode="firstPerson"
onRuntimeMessageChange={vi.fn()}
onFirstPersonTelemetryChange={vi.fn()}
onInteractionPromptChange={vi.fn()}
onSceneTransitionActivated={vi.fn()}
/>
);
await waitFor(() => {
expect(
runtimeHostInstances[0]?.setSceneLoadStateHandler
).toHaveBeenCalledTimes(1);
});
const publishSceneLoadState = runtimeHostInstances[0]
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void)
| undefined;
act(() => {
publishSceneLoadState?.({
status: "error",
message: "Runner scene failed to load: collision bootstrap exploded."
});
});
expect(
screen.getByTestId("runner-loading-overlay").className
).not.toContain("runner-canvas__loading-overlay--hidden");
expect(screen.getByTestId("runner-loading-error")).toHaveTextContent(
"Runner scene failed to load: collision bootstrap exploded."
);
expect(document.querySelector(".runner-canvas__crosshair")).toBeNull();
});
it("does not recreate the runtime host when the scene-exit callback identity changes", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
const onRuntimeMessageChange = vi.fn();
const onFirstPersonTelemetryChange = vi.fn();
const onInteractionPromptChange = vi.fn();
const { rerender } = render(
<RunnerCanvas
runtimeScene={runtimeScene}
sceneName="Stable Runner"
sceneLoadingScreen={createDefaultSceneLoadingScreenSettings()}
projectAssets={{}}
loadedModelAssets={{}}
loadedImageAssets={{}}
loadedAudioAssets={{}}
navigationMode="firstPerson"
onRuntimeMessageChange={onRuntimeMessageChange}
onFirstPersonTelemetryChange={onFirstPersonTelemetryChange}
onInteractionPromptChange={onInteractionPromptChange}
onSceneTransitionActivated={vi.fn()}
/>
);
await waitFor(() => {
expect(runtimeHostInstances).toHaveLength(1);
expect(runtimeHostInstances[0]?.loadScene).toHaveBeenCalledTimes(1);
});
rerender(
<RunnerCanvas
runtimeScene={runtimeScene}
sceneName="Stable Runner"
sceneLoadingScreen={createDefaultSceneLoadingScreenSettings()}
projectAssets={{}}
loadedModelAssets={{}}
loadedImageAssets={{}}
loadedAudioAssets={{}}
navigationMode="firstPerson"
onRuntimeMessageChange={onRuntimeMessageChange}
onFirstPersonTelemetryChange={onFirstPersonTelemetryChange}
onInteractionPromptChange={onInteractionPromptChange}
onSceneTransitionActivated={vi.fn()}
/>
);
expect(runtimeHostInstances).toHaveLength(1);
expect(runtimeHostInstances[0]?.loadScene).toHaveBeenCalledTimes(1);
expect(
runtimeHostInstances[0]?.setSceneTransitionHandler
).toHaveBeenCalledTimes(2);
});
it("does not render a DOM night background overlay because the runtime host owns background blending", async () => {
const document = createEmptySceneDocument();
document.assets["asset-night-sky"] = {
id: "asset-night-sky",
kind: "image",
sourceName: "night-sky.png",
mimeType: "image/png",
storageKey: "project-asset:asset-night-sky",
byteLength: 2048,
metadata: {
kind: "image",
width: 1024,
height: 512,
hasAlpha: false,
warnings: []
}
};
document.world.timeOfDay.night.background = {
mode: "image",
assetId: "asset-night-sky",
environmentIntensity: 0.42
};
const runtimeScene = buildRuntimeSceneFromDocument(document);
render(
<RunnerCanvas
runtimeScene={runtimeScene}
runtimeClock={{
timeOfDayHours: 0,
dayCount: 0,
dayLengthMinutes: runtimeScene.time.dayLengthMinutes
}}
sceneName="Night Runner"
sceneLoadingScreen={createDefaultSceneLoadingScreenSettings()}
projectAssets={{}}
loadedModelAssets={{}}
loadedImageAssets={{
"asset-night-sky": {
previewUrl: "/night-sky.png",
sourceUrl: "/night-sky.png"
} as never
}}
loadedAudioAssets={{}}
navigationMode="firstPerson"
onRuntimeMessageChange={vi.fn()}
onFirstPersonTelemetryChange={vi.fn()}
onInteractionPromptChange={vi.fn()}
onSceneTransitionActivated={vi.fn()}
/>
);
await waitFor(() => {
expect(runtimeHostInstances).toHaveLength(1);
});
expect(
screen.queryByTestId("runner-night-background-overlay")
).toBeNull();
});
it("keeps the crosshair hidden in third-person mode while still showing interaction prompts", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
render(
<RunnerCanvas
runtimeScene={runtimeScene}
sceneName="Third Person Runner"
sceneLoadingScreen={createDefaultSceneLoadingScreenSettings()}
projectAssets={{}}
loadedModelAssets={{}}
loadedImageAssets={{}}
loadedAudioAssets={{}}
navigationMode="thirdPerson"
onRuntimeMessageChange={vi.fn()}
onFirstPersonTelemetryChange={vi.fn()}
onInteractionPromptChange={vi.fn()}
onSceneTransitionActivated={vi.fn()}
/>
);
await waitFor(() => {
expect(runtimeHostInstances).toHaveLength(1);
expect(
runtimeHostInstances[0]?.setSceneLoadStateHandler
).toHaveBeenCalledTimes(1);
});
const publishSceneLoadState = runtimeHostInstances[0]
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void)
| undefined;
const publishInteractionPrompt = runtimeHostInstances[0]
?.setInteractionPromptHandler.mock.calls[0]?.[0] as
| ((prompt: {
sourceEntityId: string;
prompt: string;
distance: number;
range: number;
} | null) => void)
| undefined;
act(() => {
publishSceneLoadState?.({
status: "ready",
message: null
});
publishInteractionPrompt?.({
sourceEntityId: "entity-interactable-console",
prompt: "Use Console",
distance: 1.2,
range: 2
});
});
expect(document.querySelector(".runner-canvas__crosshair")).toBeNull();
expect(screen.getByTestId("runner-interaction-prompt")).toBeVisible();
expect(screen.getByTestId("runner-interaction-prompt-text")).toHaveTextContent(
"Use Console"
);
});
});