Files
webeditor3d/tests/unit/runner-canvas.test.tsx

274 lines
8.3 KiB
TypeScript
Raw Normal View History

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";
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>;
setSceneLoadStateHandler: ReturnType<typeof vi.fn>;
}> = [];
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();
setSceneLoadStateHandler = 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()}
/>
);
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?.({
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();
});
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();
});
});