From 7ff300fdc652f606085de4fe0c8ca70a3e3f18ed Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sun, 5 Apr 2026 04:25:24 +0200 Subject: [PATCH] Add tests for duplicate selection command and update box brush authoring test --- .../duplicate-selection.command.test.js | 264 ++++++++++++++++++ tests/e2e/box-brush-authoring.e2e.ts | 44 +++ 2 files changed, 308 insertions(+) create mode 100644 tests/domain/duplicate-selection.command.test.js diff --git a/tests/domain/duplicate-selection.command.test.js b/tests/domain/duplicate-selection.command.test.js new file mode 100644 index 00000000..8b76c8b6 --- /dev/null +++ b/tests/domain/duplicate-selection.command.test.js @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { createProjectAssetStorageKey } from "../../src/assets/project-assets"; +import { createEditorStore } from "../../src/app/editor-store"; +import { createDuplicateSelectionCommand } from "../../src/commands/duplicate-selection-command"; +import { createBoxBrush } from "../../src/document/brushes"; +import { createEmptySceneDocument } from "../../src/document/scene-document"; +import { createTriggerVolumeEntity } from "../../src/entities/entity-instances"; +const modelAsset = { + id: "asset-model-duplicate", + kind: "model", + sourceName: "duplicate-fixture.gltf", + mimeType: "model/gltf+json", + storageKey: createProjectAssetStorageKey("asset-model-duplicate"), + byteLength: 64, + metadata: { + kind: "model", + format: "gltf", + sceneName: "Duplicate Fixture", + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: [], + boundingBox: { + min: { + x: 0, + y: 0, + z: 0 + }, + max: { + x: 1, + y: 1, + z: 1 + }, + size: { + x: 1, + y: 1, + z: 1 + } + }, + warnings: [] + } +}; +describe("duplicate selection command", () => { + it("duplicates one selected whitebox solid with a fresh id and supports undo/redo", () => { + const sourceBrush = createBoxBrush({ + id: "brush-source", + center: { + x: 2, + y: 1, + z: -3 + } + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Duplicate Brush" }), + brushes: { + [sourceBrush.id]: sourceBrush + } + } + }); + store.setSelection({ + kind: "brushes", + ids: [sourceBrush.id] + }); + store.executeCommand(createDuplicateSelectionCommand()); + const selection = store.getState().selection; + expect(selection.kind).toBe("brushes"); + if (selection.kind !== "brushes") { + throw new Error("Expected duplicated brush selection."); + } + expect(selection.ids).toHaveLength(1); + expect(selection.ids[0]).not.toBe(sourceBrush.id); + const duplicatedBrush = store.getState().document.brushes[selection.ids[0]]; + expect(duplicatedBrush).toBeDefined(); + expect(duplicatedBrush.center).toEqual({ + x: sourceBrush.center.x + 1, + y: sourceBrush.center.y, + z: sourceBrush.center.z + 1 + }); + expect(duplicatedBrush.faces).toEqual(sourceBrush.faces); + expect(store.undo()).toBe(true); + expect(Object.keys(store.getState().document.brushes)).toEqual([sourceBrush.id]); + expect(store.redo()).toBe(true); + expect(store.getState().selection).toEqual({ + kind: "brushes", + ids: [duplicatedBrush.id] + }); + expect(store.getState().document.brushes[duplicatedBrush.id]).toEqual(duplicatedBrush); + }); + it("duplicates one selected model instance without duplicating its asset", () => { + const sourceModelInstance = createModelInstance({ + id: "model-instance-source", + assetId: modelAsset.id, + position: { + x: -4, + y: 2, + z: 5 + }, + rotationDegrees: { + x: 0, + y: 25, + z: 0 + }, + scale: { + x: 1.5, + y: 1.5, + z: 1.5 + } + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Duplicate Model Instance" }), + assets: { + [modelAsset.id]: modelAsset + }, + modelInstances: { + [sourceModelInstance.id]: sourceModelInstance + } + } + }); + store.setSelection({ + kind: "modelInstances", + ids: [sourceModelInstance.id] + }); + store.executeCommand(createDuplicateSelectionCommand()); + const selection = store.getState().selection; + expect(selection.kind).toBe("modelInstances"); + if (selection.kind !== "modelInstances") { + throw new Error("Expected duplicated model instance selection."); + } + const duplicatedModelInstanceId = selection.ids[0]; + expect(duplicatedModelInstanceId).not.toBe(sourceModelInstance.id); + const duplicatedModelInstance = store.getState().document.modelInstances[duplicatedModelInstanceId]; + expect(duplicatedModelInstance.assetId).toBe(sourceModelInstance.assetId); + expect(duplicatedModelInstance.position).toEqual({ + x: sourceModelInstance.position.x + 1, + y: sourceModelInstance.position.y, + z: sourceModelInstance.position.z + 1 + }); + expect(store.getState().document.assets[modelAsset.id]).toEqual(modelAsset); + expect(Object.keys(store.getState().document.assets)).toHaveLength(1); + expect(store.undo()).toBe(true); + expect(store.getState().document.modelInstances[sourceModelInstance.id]).toEqual(sourceModelInstance); + expect(store.getState().document.modelInstances[duplicatedModelInstanceId]).toBeUndefined(); + expect(store.redo()).toBe(true); + expect(store.getState().selection).toEqual({ + kind: "modelInstances", + ids: [duplicatedModelInstanceId] + }); + }); + it("duplicates one selected entity with a fresh id and selects the duplicate", () => { + const sourceEntity = createTriggerVolumeEntity({ + id: "entity-source-trigger", + position: { + x: 8, + y: 1, + z: -2 + }, + size: { + x: 4, + y: 3, + z: 2 + }, + triggerOnEnter: false, + triggerOnExit: true + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Duplicate Entity" }), + entities: { + [sourceEntity.id]: sourceEntity + } + } + }); + store.setSelection({ + kind: "entities", + ids: [sourceEntity.id] + }); + store.executeCommand(createDuplicateSelectionCommand()); + const selection = store.getState().selection; + expect(selection.kind).toBe("entities"); + if (selection.kind !== "entities") { + throw new Error("Expected duplicated entity selection."); + } + expect(selection.ids).toHaveLength(1); + expect(selection.ids[0]).not.toBe(sourceEntity.id); + const duplicatedEntity = store.getState().document.entities[selection.ids[0]]; + expect(duplicatedEntity).toBeDefined(); + expect(duplicatedEntity.kind).toBe(sourceEntity.kind); + expect(duplicatedEntity.position).toEqual({ + x: sourceEntity.position.x + 1, + y: sourceEntity.position.y, + z: sourceEntity.position.z + 1 + }); + expect(store.undo()).toBe(true); + expect(store.getState().document.entities[sourceEntity.id]).toEqual(sourceEntity); + expect(store.redo()).toBe(true); + expect(store.getState().selection).toEqual({ + kind: "entities", + ids: [duplicatedEntity.id] + }); + }); + it("duplicates multiple selected whitebox solids in one operation", () => { + const sourceBrushA = createBoxBrush({ + id: "brush-source-a", + center: { + x: 0, + y: 1, + z: 0 + } + }); + const sourceBrushB = createBoxBrush({ + id: "brush-source-b", + center: { + x: 6, + y: 2, + z: -4 + } + }); + const store = createEditorStore({ + initialDocument: { + ...createEmptySceneDocument({ name: "Duplicate Multi" }), + brushes: { + [sourceBrushA.id]: sourceBrushA, + [sourceBrushB.id]: sourceBrushB + } + } + }); + store.setSelection({ + kind: "brushes", + ids: [sourceBrushA.id, sourceBrushB.id] + }); + store.executeCommand(createDuplicateSelectionCommand()); + const selection = store.getState().selection; + expect(selection.kind).toBe("brushes"); + if (selection.kind !== "brushes") { + throw new Error("Expected duplicated multi-brush selection."); + } + expect(selection.ids).toHaveLength(2); + expect(new Set(selection.ids).size).toBe(2); + expect(selection.ids).not.toEqual([sourceBrushA.id, sourceBrushB.id]); + const duplicatedBrushA = store.getState().document.brushes[selection.ids[0]]; + const duplicatedBrushB = store.getState().document.brushes[selection.ids[1]]; + expect(duplicatedBrushA.center).toEqual({ + x: sourceBrushA.center.x + 1, + y: sourceBrushA.center.y, + z: sourceBrushA.center.z + 1 + }); + expect(duplicatedBrushB.center).toEqual({ + x: sourceBrushB.center.x + 1, + y: sourceBrushB.center.y, + z: sourceBrushB.center.z + 1 + }); + expect(store.undo()).toBe(true); + expect(Object.keys(store.getState().document.brushes).sort()).toEqual([sourceBrushA.id, sourceBrushB.id]); + expect(store.redo()).toBe(true); + expect(store.getState().selection).toEqual({ + kind: "brushes", + ids: [duplicatedBrushA.id, duplicatedBrushB.id] + }); + }); +}); diff --git a/tests/e2e/box-brush-authoring.e2e.ts b/tests/e2e/box-brush-authoring.e2e.ts index 016160cd..80166f75 100644 --- a/tests/e2e/box-brush-authoring.e2e.ts +++ b/tests/e2e/box-brush-authoring.e2e.ts @@ -117,3 +117,47 @@ test("switching selection while a transform input is active does not overwrite t await outlinerButtons.nth(0).click(); await expect(page.getByTestId("brush-size-z")).toHaveValue("4"); }); + +test("shift+d duplicates the current selection and does not trigger while typing", async ({ page }) => { + await page.goto("/"); + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, "webeditor3d.scene-document-draft"); + await page.reload(); + + await beginBoxCreation(page); + await clickViewport(page); + + const beforeDuplicateSnapshot = await getEditorStoreSnapshot(page); + expect(beforeDuplicateSnapshot.selection).toMatchObject({ + kind: "brushes" + }); + const sourceBrushId = beforeDuplicateSnapshot.selection.ids?.[0]; + expect(sourceBrushId).toBeDefined(); + + await page.keyboard.press("Shift+D"); + + const afterDuplicateSnapshot = await getEditorStoreSnapshot(page); + expect(afterDuplicateSnapshot.selection).toMatchObject({ + kind: "brushes" + }); + expect(Object.keys(afterDuplicateSnapshot.document.brushes)).toHaveLength(2); + + const duplicatedBrushId = afterDuplicateSnapshot.selection.ids?.[0]; + expect(duplicatedBrushId).toBeDefined(); + expect(duplicatedBrushId).not.toBe(sourceBrushId); + + const sourceCenter = beforeDuplicateSnapshot.document.brushes[sourceBrushId as string].center; + const duplicatedCenter = afterDuplicateSnapshot.document.brushes[duplicatedBrushId as string].center; + expect(duplicatedCenter).toEqual({ + x: sourceCenter.x + 1, + y: sourceCenter.y, + z: sourceCenter.z + 1 + }); + + await page.getByTestId("selected-brush-name").click(); + await page.keyboard.press("Shift+D"); + + const afterTypingShortcutSnapshot = await getEditorStoreSnapshot(page); + expect(Object.keys(afterTypingShortcutSnapshot.document.brushes)).toHaveLength(2); +});