From f2682b0d9b4fdc85262d9a1f02bd35a367178fab Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 31 Mar 2026 03:10:13 +0200 Subject: [PATCH] Add tests and update styles for runner workspace --- src/app/app.css | 56 ++++++- tests/domain/build-runtime-scene.test.ts | 142 ++++++++++++++++++ tests/domain/editor-store.test.ts | 13 ++ tests/e2e/app-smoke.e2e.ts | 1 + tests/e2e/runner-v1.e2e.ts | 44 ++++++ .../serialization/scene-document-json.test.ts | 43 +++++- 6 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 tests/domain/build-runtime-scene.test.ts create mode 100644 tests/e2e/runner-v1.e2e.ts diff --git a/src/app/app.css b/src/app/app.css index 812c2fc0..59c9db06 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -144,6 +144,13 @@ button:disabled { min-height: 0; } +.runner-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 320px); + gap: 12px; + min-height: 0; +} + .side-column { display: flex; flex-direction: column; @@ -266,6 +273,12 @@ button:disabled { gap: 10px; } +.outliner-section { + display: flex; + flex-direction: column; + gap: 12px; +} + .outliner-item { display: flex; flex-direction: column; @@ -402,6 +415,16 @@ button:disabled { box-shadow: var(--shadow-panel); } +.runner-region { + display: flex; + min-height: 0; + background: rgba(20, 24, 31, 0.92); + border: 1px solid var(--color-border); + border-radius: 24px; + overflow: hidden; + box-shadow: var(--shadow-panel); +} + .viewport-region__header { display: flex; align-items: center; @@ -423,22 +446,33 @@ button:disabled { font-size: 0.82rem; } -.viewport-canvas { +.viewport-canvas, +.runner-canvas { position: relative; min-height: 420px; - cursor: crosshair; background: radial-gradient(circle at top, rgba(130, 154, 188, 0.28) 0%, rgba(130, 154, 188, 0) 38%), linear-gradient(180deg, #55657c 0%, #2c3440 34%, #151920 100%); } -.viewport-canvas canvas { +.viewport-canvas { + cursor: crosshair; +} + +.runner-canvas { + flex: 1 1 auto; + cursor: grab; +} + +.viewport-canvas canvas, +.runner-canvas canvas { display: block; width: 100%; height: 100%; } -.viewport-canvas__fallback { +.viewport-canvas__fallback, +.runner-canvas__fallback { position: absolute; inset: 18px; display: flex; @@ -452,7 +486,8 @@ button:disabled { border-radius: 18px; } -.viewport-canvas__fallback-title { +.viewport-canvas__fallback-title, +.runner-canvas__fallback-title { font-size: 0.78rem; font-weight: 700; letter-spacing: 0.14em; @@ -493,6 +528,10 @@ button:disabled { grid-template-columns: minmax(240px, 280px) minmax(0, 1fr); } + .runner-workspace { + grid-template-columns: 1fr; + } + .workspace > .side-column:last-child { grid-column: 1 / -1; display: grid; @@ -515,11 +554,16 @@ button:disabled { grid-template-columns: 1fr; } + .runner-workspace { + grid-template-columns: 1fr; + } + .workspace > .side-column:last-child { display: flex; } - .viewport-canvas { + .viewport-canvas, + .runner-canvas { min-height: 320px; } diff --git a/tests/domain/build-runtime-scene.test.ts b/tests/domain/build-runtime-scene.test.ts new file mode 100644 index 00000000..fe31ed2a --- /dev/null +++ b/tests/domain/build-runtime-scene.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; + +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { createPlayerStartEntity } from "../../src/entities/entity-instances"; +import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; + +describe("buildRuntimeSceneFromDocument", () => { + it("builds runtime brush data, colliders, and an authored player spawn from the document", () => { + const brush = createBoxBrush({ + id: "brush-room-floor", + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 8, + y: 1, + z: 8 + } + }); + brush.faces.posY.materialId = "starter-concrete-checker"; + + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-main", + position: { + x: 2, + y: 0, + z: -1 + }, + yawDegrees: 90 + }); + + const document = { + ...createEmptySceneDocument({ name: "Runtime Slice" }), + brushes: { + [brush.id]: brush + }, + entities: { + [playerStart.id]: playerStart + } + }; + + const runtimeScene = buildRuntimeSceneFromDocument(document); + + expect(runtimeScene.brushes).toHaveLength(1); + expect(runtimeScene.brushes[0].faces.posY.material?.id).toBe("starter-concrete-checker"); + expect(runtimeScene.colliders).toEqual([ + { + kind: "box", + brushId: "brush-room-floor", + min: { + x: -4, + y: -1, + z: -4 + }, + max: { + x: 4, + y: 0, + z: 4 + } + } + ]); + expect(runtimeScene.sceneBounds).toEqual({ + min: { + x: -4, + y: -1, + z: -4 + }, + max: { + x: 4, + y: 0, + z: 4 + }, + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 8, + y: 1, + z: 8 + } + }); + expect(runtimeScene.playerStart).toEqual({ + entityId: "entity-player-start-main", + position: { + x: 2, + y: 0, + z: -1 + }, + yawDegrees: 90 + }); + expect(runtimeScene.spawn).toEqual({ + source: "playerStart", + entityId: "entity-player-start-main", + position: { + x: 2, + y: 0, + z: -1 + }, + yawDegrees: 90 + }); + }); + + it("builds a deterministic fallback spawn when no PlayerStart is authored", () => { + const brush = createBoxBrush({ + id: "brush-room-wall", + center: { + x: 0, + y: 1, + z: 0 + }, + size: { + x: 6, + y: 2, + z: 6 + } + }); + + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Fallback Runtime Scene" }), + brushes: { + [brush.id]: brush + } + }); + + expect(runtimeScene.playerStart).toBeNull(); + expect(runtimeScene.spawn).toEqual({ + source: "fallback", + entityId: null, + position: { + x: 0, + y: 2.1, + z: 6 + }, + yawDegrees: 180 + }); + }); +}); diff --git a/tests/domain/editor-store.test.ts b/tests/domain/editor-store.test.ts index 8468f918..9959f309 100644 --- a/tests/domain/editor-store.test.ts +++ b/tests/domain/editor-store.test.ts @@ -100,4 +100,17 @@ describe("EditorStore", () => { message: expect.stringContaining("blocked read") }); }); + + it("restores the previous editor tool when leaving play mode", () => { + const store = createEditorStore(); + + store.setToolMode("box-create"); + store.enterPlayMode(); + + expect(store.getState().toolMode).toBe("play"); + + store.exitPlayMode(); + + expect(store.getState().toolMode).toBe("box-create"); + }); }); diff --git a/tests/e2e/app-smoke.e2e.ts b/tests/e2e/app-smoke.e2e.ts index 8632d6f4..9e7dce47 100644 --- a/tests/e2e/app-smoke.e2e.ts +++ b/tests/e2e/app-smoke.e2e.ts @@ -18,6 +18,7 @@ test("app boots and shows the viewport shell", async ({ page }) => { await expect(page.getByText("WebEditor3D")).toBeVisible(); await expect(page.getByTestId("viewport-shell")).toBeVisible(); + await expect(page.getByTestId("enter-run-mode")).toBeVisible(); expect(pageErrors).toEqual([]); expect(consoleErrors).toEqual([]); diff --git a/tests/e2e/runner-v1.e2e.ts b/tests/e2e/runner-v1.e2e.ts new file mode 100644 index 00000000..97e9b49b --- /dev/null +++ b/tests/e2e/runner-v1.e2e.ts @@ -0,0 +1,44 @@ +import { expect, test } from "@playwright/test"; + +test("user can place PlayerStart, enter run mode, and spawn from it", async ({ page }) => { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + + await page.getByTestId("place-player-start").click(); + await page.getByTestId("player-start-position-x").fill("4"); + await page.getByTestId("player-start-position-y").fill("0"); + await page.getByTestId("player-start-position-z").fill("-2"); + await page.getByTestId("player-start-yaw").fill("90"); + await page.getByTestId("apply-player-start").click(); + + await page.getByTestId("enter-run-mode").click(); + + await expect(page.getByTestId("runner-shell")).toBeVisible(); + await expect(page.getByTestId("runner-spawn-state")).toContainText("Player Start"); + await expect(page.getByTestId("runner-player-position")).toContainText("4.00, 0.00, -2.00"); + + await page.getByTestId("runner-mode-orbit-visitor").click(); + await expect(page.getByText("Orbit Visitor")).toBeVisible(); + + await page.getByTestId("exit-run-mode").click(); + await expect(page.getByTestId("viewport-shell")).toBeVisible(); + + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts index e8f6a106..25bc13d0 100644 --- a/tests/serialization/scene-document-json.test.ts +++ b/tests/serialization/scene-document-json.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { createBoxBrush } from "../../src/document/brushes"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { migrateSceneDocument } from "../../src/document/migrate-scene-document"; +import { createPlayerStartEntity } from "../../src/entities/entity-instances"; import { STARTER_MATERIAL_LIBRARY } from "../../src/materials/starter-material-library"; import { parseSceneDocumentJson, serializeSceneDocument } from "../../src/serialization/scene-document-json"; @@ -78,6 +79,26 @@ describe("scene document JSON", () => { expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); }); + it("round-trips a document containing an authored PlayerStart entity", () => { + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-main", + position: { + x: 4, + y: 0, + z: -2 + }, + yawDegrees: 135 + }); + const document = { + ...createEmptySceneDocument({ name: "Player Start Scene" }), + entities: { + [playerStart.id]: playerStart + } + }; + + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); + }); + it("migrates the foundation schema to the current schema version", () => { const migratedDocument = migrateSceneDocument({ version: 1, @@ -92,7 +113,7 @@ describe("scene document JSON", () => { interactionLinks: {} }); - expect(migratedDocument.version).toBe(3); + expect(migratedDocument.version).toBe(4); expect(migratedDocument.brushes).toEqual({}); expect(migratedDocument.name).toBe("Foundation Scene"); expect(Object.keys(migratedDocument.materials)).toEqual(STARTER_MATERIAL_LIBRARY.map((material) => material.id)); @@ -135,7 +156,7 @@ describe("scene document JSON", () => { interactionLinks: {} }); - expect(migratedDocument.version).toBe(3); + expect(migratedDocument.version).toBe(4); expect(migratedDocument.brushes["brush-legacy"].faces.posZ.materialId).toBe("starter-amber-grid"); expect(migratedDocument.brushes["brush-legacy"].faces.posZ.uv).toEqual({ offset: { @@ -152,6 +173,24 @@ describe("scene document JSON", () => { }); }); + it("migrates slice 1.2 face materials to the PlayerStart-capable schema", () => { + const migratedDocument = migrateSceneDocument({ + version: 3, + name: "Legacy Face Scene", + world: createEmptySceneDocument().world, + materials: createEmptySceneDocument().materials, + textures: {}, + assets: {}, + brushes: {}, + modelInstances: {}, + entities: {}, + interactionLinks: {} + }); + + expect(migratedDocument.version).toBe(4); + expect(migratedDocument.entities).toEqual({}); + }); + it("rejects unsupported versions", () => { expect(() => migrateSceneDocument({