diff --git a/tests/domain/custom-material.command.test.ts b/tests/domain/custom-material.command.test.ts new file mode 100644 index 00000000..6d8c5508 --- /dev/null +++ b/tests/domain/custom-material.command.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; + +import { createEditorStore } from "../../src/app/editor-store"; +import { createCreateBoxBrushCommand } from "../../src/commands/create-box-brush-command"; +import { createCreateCustomMaterialCommand } from "../../src/commands/create-custom-material-command"; +import { createSetCustomMaterialTextureCommand } from "../../src/commands/set-custom-material-texture-command"; +import { createUpdateCustomMaterialCommand } from "../../src/commands/update-custom-material-command"; +import { + createProjectAssetStorageKey, + type ImageAssetRecord +} from "../../src/assets/project-assets"; +import { + BOX_FACE_IDS, + type WhiteboxFaceId +} from "../../src/document/brushes"; +import { createCustomMaterialDef } from "../../src/materials/starter-material-library"; + +function createImageAsset(id: string): ImageAssetRecord { + return { + id, + kind: "image", + sourceName: `${id}.png`, + mimeType: "image/png", + storageKey: createProjectAssetStorageKey(id), + byteLength: 128, + metadata: { + kind: "image", + width: 4, + height: 4, + hasAlpha: false, + warnings: [] + } + }; +} + +describe("custom material commands", () => { + it("creates a custom material and applies it to the selected face with undo/redo", () => { + const store = createEditorStore(); + const material = createCustomMaterialDef({ + id: "material-custom-face", + name: "Blue Test", + albedoColorHex: "#3366ff" + }); + + store.executeCommand(createCreateBoxBrushCommand()); + + const brush = Object.values(store.getState().document.brushes)[0]; + + store.executeCommand( + createCreateCustomMaterialCommand({ + material, + applyTo: { + kind: "face", + brushId: brush.id, + faceId: "posZ" + } + }) + ); + + expect(store.getState().document.materials[material.id]).toMatchObject({ + kind: "custom", + name: "Blue Test", + albedoColorHex: "#3366ff" + }); + expect( + store.getState().document.brushes[brush.id].faces.posZ.materialId + ).toBe(material.id); + expect(store.getState().selection).toEqual({ + kind: "brushFace", + brushId: brush.id, + faceId: "posZ" + }); + + expect(store.undo()).toBe(true); + expect(store.getState().document.materials[material.id]).toBeUndefined(); + expect( + store.getState().document.brushes[brush.id].faces.posZ.materialId + ).toBeNull(); + + expect(store.redo()).toBe(true); + expect(store.getState().document.materials[material.id]).toBeDefined(); + expect( + store.getState().document.brushes[brush.id].faces.posZ.materialId + ).toBe(material.id); + }); + + it("creates a custom material and applies it to all selected solid faces", () => { + const store = createEditorStore(); + const material = createCustomMaterialDef({ + id: "material-custom-solid", + name: "Solid Test" + }); + + store.executeCommand(createCreateBoxBrushCommand()); + + const brush = Object.values(store.getState().document.brushes)[0]; + + store.executeCommand( + createCreateCustomMaterialCommand({ + material, + applyTo: { + kind: "brush", + brushId: brush.id + } + }) + ); + + for (const faceId of BOX_FACE_IDS) { + expect( + store.getState().document.brushes[brush.id].faces[ + faceId as WhiteboxFaceId + ].materialId + ).toBe(material.id); + } + + expect(store.undo()).toBe(true); + + for (const faceId of BOX_FACE_IDS) { + expect( + store.getState().document.brushes[brush.id].faces[ + faceId as WhiteboxFaceId + ].materialId + ).toBeNull(); + } + }); + + it("updates custom material scalar settings and restores them on undo", () => { + const store = createEditorStore(); + const material = createCustomMaterialDef({ + id: "material-custom-update", + roughness: 0.8, + metallic: 0 + }); + + store.executeCommand( + createCreateCustomMaterialCommand({ + material + }) + ); + store.executeCommand( + createUpdateCustomMaterialCommand({ + materialId: material.id, + update: { + opacity: 0.45, + roughness: 0.2, + metallic: 0.9, + normalStrength: 0.35 + } + }) + ); + + expect(store.getState().document.materials[material.id]).toMatchObject({ + opacity: 0.45, + roughness: 0.2, + metallic: 0.9, + normalStrength: 0.35 + }); + + expect(store.undo()).toBe(true); + expect(store.getState().document.materials[material.id]).toMatchObject({ + opacity: 1, + roughness: 0.8, + metallic: 0, + normalStrength: 1 + }); + }); + + it("imports and assigns image texture assets to custom material slots", () => { + const store = createEditorStore(); + const material = createCustomMaterialDef({ + id: "material-custom-texture" + }); + const imageAsset = createImageAsset("asset-material-albedo"); + + store.executeCommand( + createCreateCustomMaterialCommand({ + material + }) + ); + store.executeCommand( + createSetCustomMaterialTextureCommand({ + materialId: material.id, + slot: "albedo", + assetId: imageAsset.id, + asset: imageAsset + }) + ); + + const assignedMaterial = store.getState().document.materials[material.id]; + + expect(store.getState().document.assets[imageAsset.id]).toEqual(imageAsset); + expect(assignedMaterial).toMatchObject({ + kind: "custom", + textures: { + albedo: { + assetId: imageAsset.id + } + } + }); + + expect(store.undo()).toBe(true); + expect(store.getState().document.assets[imageAsset.id]).toBeUndefined(); + expect(store.getState().document.materials[material.id]).toMatchObject({ + textures: { + albedo: null + } + }); + + expect(store.redo()).toBe(true); + expect(store.getState().document.assets[imageAsset.id]).toEqual(imageAsset); + expect(store.getState().document.materials[material.id]).toMatchObject({ + textures: { + albedo: { + assetId: imageAsset.id + } + } + }); + }); + + it("keeps starter materials read-only", () => { + const store = createEditorStore(); + + expect(() => + store.executeCommand( + createUpdateCustomMaterialCommand({ + materialId: "starter-amber-grid", + update: { + roughness: 0.4 + } + }) + ) + ).toThrow("Starter materials are read-only."); + }); +});