2026-04-07 05:29:41 +02:00
|
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
|
|
|
import { act } from "react";
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
|
2026-04-11 04:17:57 +02:00
|
|
|
import {
|
|
|
|
|
createDefaultSceneLoadingScreenSettings,
|
|
|
|
|
createEmptySceneDocument
|
|
|
|
|
} from "../../src/document/scene-document";
|
2026-04-07 05:29:41 +02:00
|
|
|
import { RunnerCanvas } from "../../src/runner-web/RunnerCanvas";
|
|
|
|
|
import type { FirstPersonTelemetry } from "../../src/runtime-three/navigation-controller";
|
2026-04-11 04:17:57 +02:00
|
|
|
import type { RuntimeSceneLoadState } from "../../src/runtime-three/runtime-host";
|
2026-04-07 05:29:41 +02:00
|
|
|
import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build";
|
|
|
|
|
|
|
|
|
|
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>;
|
|
|
|
|
setRuntimeMessageHandler: ReturnType<typeof vi.fn>;
|
|
|
|
|
setFirstPersonTelemetryHandler: ReturnType<typeof vi.fn>;
|
|
|
|
|
setInteractionPromptHandler: ReturnType<typeof vi.fn>;
|
2026-04-11 04:17:57 +02:00
|
|
|
setSceneLoadStateHandler: ReturnType<typeof vi.fn>;
|
2026-04-07 05:29:41 +02:00
|
|
|
}> = [];
|
|
|
|
|
|
|
|
|
|
class MockRuntimeHost {
|
|
|
|
|
mount = vi.fn();
|
|
|
|
|
dispose = vi.fn();
|
|
|
|
|
loadScene = vi.fn();
|
|
|
|
|
updateAssets = vi.fn();
|
|
|
|
|
setNavigationMode = vi.fn();
|
|
|
|
|
setRuntimeMessageHandler = vi.fn();
|
|
|
|
|
setFirstPersonTelemetryHandler = vi.fn();
|
|
|
|
|
setInteractionPromptHandler = vi.fn();
|
2026-04-11 04:17:57 +02:00
|
|
|
setSceneLoadStateHandler = vi.fn();
|
2026-04-07 05:29:41 +02:00
|
|
|
|
|
|
|
|
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}
|
2026-04-11 04:17:57 +02:00
|
|
|
sceneName="Underwater Test"
|
|
|
|
|
sceneLoadingScreen={createDefaultSceneLoadingScreenSettings()}
|
2026-04-07 05:29:41 +02:00
|
|
|
projectAssets={{}}
|
|
|
|
|
loadedModelAssets={{}}
|
|
|
|
|
loadedImageAssets={{}}
|
|
|
|
|
loadedAudioAssets={{}}
|
|
|
|
|
navigationMode="firstPerson"
|
|
|
|
|
onRuntimeMessageChange={vi.fn()}
|
|
|
|
|
onFirstPersonTelemetryChange={onTelemetryChange}
|
|
|
|
|
onInteractionPromptChange={vi.fn()}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(runtimeHostInstances).toHaveLength(1);
|
|
|
|
|
expect(runtimeHostInstances[0]?.setFirstPersonTelemetryHandler).toHaveBeenCalledTimes(1);
|
2026-04-11 04:17:57 +02:00
|
|
|
expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1);
|
2026-04-07 05:29:41 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const publishTelemetry = runtimeHostInstances[0]?.setFirstPersonTelemetryHandler.mock.calls[0]?.[0] as ((telemetry: FirstPersonTelemetry | null) => void) | undefined;
|
2026-04-11 04:17:57 +02:00
|
|
|
const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
|
|
|
|
|
| ((state: RuntimeSceneLoadState) => void)
|
|
|
|
|
| undefined;
|
2026-04-07 05:29:41 +02:00
|
|
|
|
|
|
|
|
expect(publishTelemetry).toBeDefined();
|
2026-04-11 04:17:57 +02:00
|
|
|
expect(publishSceneLoadState).toBeDefined();
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
publishSceneLoadState?.({
|
|
|
|
|
status: "ready",
|
|
|
|
|
message: null
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-07 05:29:41 +02:00
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
publishTelemetry?.({
|
|
|
|
|
feetPosition: { x: 0, y: 0, z: 0 },
|
|
|
|
|
eyePosition: { x: 0, y: 1.7, z: 0 },
|
|
|
|
|
grounded: false,
|
|
|
|
|
locomotionState: "swimming",
|
|
|
|
|
inWaterVolume: true,
|
|
|
|
|
cameraSubmerged: false,
|
|
|
|
|
inFogVolume: false,
|
|
|
|
|
pointerLocked: true,
|
|
|
|
|
spawn: runtimeScene.spawn
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(screen.queryByLabelText("Built-in scene runner")?.className).not.toContain("runner-canvas--underwater");
|
|
|
|
|
expect(document.querySelector(".runner-canvas__underwater")).toBeNull();
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
publishTelemetry?.({
|
|
|
|
|
feetPosition: { x: 0, y: 0, z: 0 },
|
|
|
|
|
eyePosition: { x: 0, y: 0.4, z: 0 },
|
|
|
|
|
grounded: false,
|
|
|
|
|
locomotionState: "swimming",
|
|
|
|
|
inWaterVolume: true,
|
|
|
|
|
cameraSubmerged: true,
|
|
|
|
|
inFogVolume: false,
|
|
|
|
|
pointerLocked: true,
|
|
|
|
|
spawn: runtimeScene.spawn
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(screen.getByLabelText("Built-in scene runner").className).toContain("runner-canvas--underwater");
|
|
|
|
|
expect(document.querySelector(".runner-canvas__underwater")).not.toBeNull();
|
|
|
|
|
});
|
2026-04-11 04:17:57 +02:00
|
|
|
|
|
|
|
|
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()}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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("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()}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|