Files
image-studio/commands/document.test.ts
syntaxbullet f5c610dac5 feat: implement inpainting functionality with mask handling
- Added `createMaskedPixelReplacementSource` function to handle pixel replacement using inpainting.
- Introduced `buildInpaintBundle` to prepare inpainting data including mask generation and validation.
- Created utility functions for mask operations such as `applyMaskedContentModeToRgba`, `expandRectWithinBounds`, and others for mask manipulation.
- Developed tests for inpainting preparation and mask raster utilities to ensure functionality and correctness.
- Implemented mask raster operations including inversion, feathering, blurring, and more.
2026-07-05 09:35:16 +02:00

357 lines
14 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { Layer } from "@core/layer";
import { createInitialAppState } from "@editor/initial-state";
import {
documentAddArtboardCommand,
documentAddAssetCommand,
documentAddGroupLayerCommand,
documentAddImageLayerCommand,
documentAddLayerMaskCommand,
documentAddRasterLayerCommand,
documentApplyLayerMaskOperationCommand,
documentGroupLayersCommand,
documentMoveLayerCommand,
documentRemoveArtboardCommand,
documentRemoveLayerCommand,
documentRemoveLayerMaskCommand,
documentRenameArtboardCommand,
documentRenameLayerCommand,
documentSetArtboardBoundsCommand,
documentSetArtboardLockedCommand,
documentSetArtboardVisibleCommand,
documentSetLayerClippingMaskCommand,
documentSetLayerLockedCommand,
documentSetLayerVisibleCommand,
documentUpdateAssetSourceCommand,
documentUngroupLayerCommand,
} from "./document";
describe("document commands", () => {
test("adds transparent artboard", () => {
const next = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
);
expect(next.document.artboards).toEqual([
{
id: "a1",
name: "Artboard 1",
bounds: { x: 0, y: 0, w: 320, h: 240 },
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [],
},
]);
});
test("adds assets", () => {
const next = documentAddAssetCommand.execute(
{ state: createInitialAppState("Test") },
{ asset: { id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 100, h: 50 } } },
);
expect(next.document.assets).toEqual([
{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 100, h: 50 } },
]);
});
test("updates asset sources", () => {
const state = documentAddAssetCommand.execute(
{ state: createInitialAppState("Test") },
{ asset: { id: "asset-1", name: "Image", mimeType: "image/png", source: "old", intrinsicSize: { w: 100, h: 50 } } },
);
const next = documentUpdateAssetSourceCommand.execute({ state }, { assetId: "asset-1", source: "new" });
expect(next.document.assets[0]?.source).toBe("new");
});
test("adds image layers to artboards", () => {
const state = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
);
const next = documentAddImageLayerCommand.execute(
{ state },
{
artboardId: "a1",
layer: {
id: "l1",
type: "image",
name: "Image",
visible: true,
locked: false,
opacity: 1,
assetId: "asset-1",
transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 },
},
},
);
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["l1"]);
});
test("adds raster layers to artboards", () => {
const state = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
);
const next = documentAddRasterLayerCommand.execute(
{ state },
{
artboardId: "a1",
layer: {
id: "r1",
type: "raster",
name: "Raster",
visible: true,
locked: false,
opacity: 1,
assetId: "asset-1",
transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 },
},
},
);
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["r1"]);
});
test("adds group layers", () => {
const state = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
);
const next = documentAddGroupLayerCommand.execute({ state }, { artboardId: "a1", group: group("g1", "Group") });
expect(next.document.artboards[0]?.layers).toEqual([group("g1", "Group")]);
expect(next.editor.selection.layerIds).toEqual(["g1"]);
});
test("moves layers", () => {
const state = documentWithLayers([group("a", "A"), group("b", "B"), group("c", "C")]);
const next = documentMoveLayerCommand.execute({ state }, { layerId: "a", toArtboardId: "a1", toIndex: 2 });
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["b", "c", "a"]);
});
test("groups and ungroups top-level layers", () => {
const state = documentWithLayers([group("a", "A"), group("b", "B"), group("c", "C")]);
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
expect(grouped.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["g", "c"]);
expect((grouped.document.artboards[0]?.layers[0] as { children: { id: string }[] }).children.map((layer) => layer.id)).toEqual(["a", "b"]);
expect(grouped.editor.selection.layerIds).toEqual(["g"]);
const ungrouped = documentUngroupLayerCommand.execute({ state: grouped }, { groupId: "g" });
expect(ungrouped.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["a", "b", "c"]);
expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]);
});
test("removes layers", () => {
const state = documentWithLayers([group("a", "A"), group("b", "B")]);
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } };
const next = documentRemoveLayerCommand.execute({ state: selectedState }, { layerId: "a" });
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["b"]);
expect(next.editor.selection.layerIds).toEqual([]);
});
test("removes artboards and clears artboard selection", () => {
const state = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
);
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: [] } } };
const next = documentRemoveArtboardCommand.execute({ state: selectedState }, { id: "a1" });
expect(next.document.artboards).toEqual([]);
expect(next.editor.selection).toEqual({ layerIds: [] });
});
test("renames artboards and layers", () => {
const state = documentWithLayers([group("a", "A")]);
const renamedArtboard = documentRenameArtboardCommand.execute({ state }, { id: "a1", name: "New Artboard" });
const renamedLayer = documentRenameLayerCommand.execute({ state: renamedArtboard }, { layerId: "a", name: "New Layer" });
expect(renamedLayer.document.artboards[0]?.name).toBe("New Artboard");
expect(renamedLayer.document.artboards[0]?.layers[0]?.name).toBe("New Layer");
});
test("sets and clears layer clipping masks", () => {
const state = documentWithLayers([group("mask", "Mask"), group("target", "Target")]);
const masked = documentSetLayerClippingMaskCommand.execute({ state }, { layerId: "target", maskLayerId: "mask" });
expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
const cleared = documentSetLayerClippingMaskCommand.execute({ state: masked }, { layerId: "target" });
expect(cleared.document.artboards[0]?.layers[1]?.clippingMask).toBeUndefined();
});
test("moves masked layers directly after their mask", () => {
const state = documentWithLayers([group("target", "Target"), group("other", "Other"), group("mask", "Mask")]);
const masked = documentSetLayerClippingMaskCommand.execute({ state }, { layerId: "target", maskLayerId: "mask" });
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["other", "mask", "target"]);
expect(masked.document.artboards[0]?.layers[2]?.clippingMask).toEqual({ maskLayerId: "mask" });
});
test("adds editable raster masks directly before the target layer", () => {
const state = documentWithLayers([raster("target", "Target")]);
const masked = documentAddLayerMaskCommand.execute(
{ state },
{
layerId: "target",
asset: maskAsset(),
maskLayer: raster("mask", "Target Mask", "mask-asset"),
},
);
expect(masked.document.assets).toContainEqual(maskAsset());
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]);
expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
expect(masked.editor.tools.activeTool).toBe("brush");
});
test("removes attached layer masks and exits mask editing", () => {
const state = documentAddLayerMaskCommand.execute(
{ state: documentWithLayers([raster("target", "Target")]) },
{ layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") },
);
const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" });
expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]);
expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
expect(unmasked.editor.maskEdit).toBeUndefined();
});
test("applies operations only to attached layer mask assets", () => {
const state = documentAddLayerMaskCommand.execute(
{ state: documentWithLayers([raster("target", "Target"), raster("loose", "Loose", "loose-asset")]) },
{ layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") },
);
const withLooseAsset = documentAddAssetCommand.execute(
{ state },
{ asset: { id: "loose-asset", name: "Loose", mimeType: "image/png", source: "loose-source", intrinsicSize: { w: 100, h: 100 } } },
);
const updated = documentApplyLayerMaskOperationCommand.execute(
{ state: withLooseAsset },
{ maskLayerId: "mask", source: "updated-mask", mimeType: "image/png", operation: { type: "invert" } },
);
const ignored = documentApplyLayerMaskOperationCommand.execute(
{ state: updated },
{ maskLayerId: "loose", source: "wrong", operation: { type: "invert" } },
);
expect(updated.document.assets.find((asset) => asset.id === "mask-asset")?.source).toBe("updated-mask");
expect(updated.document.assets.find((asset) => asset.id === "mask-asset")?.mimeType).toBe("image/png");
expect(ignored.document.assets.find((asset) => asset.id === "loose-asset")?.source).toBe("loose-source");
});
test("cleans mask references when deleting targets or mask layers", () => {
const state = documentAddLayerMaskCommand.execute(
{ state: documentWithLayers([raster("target", "Target")]) },
{ layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") },
);
const removedTarget = documentRemoveLayerCommand.execute({ state }, { layerId: "target" });
const removedMask = documentRemoveLayerCommand.execute({ state }, { layerId: "mask" });
expect(removedTarget.document.artboards[0]?.layers).toEqual([]);
expect(removedMask.document.artboards[0]?.layers[0]?.id).toBe("target");
expect(removedMask.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
expect(removedMask.editor.maskEdit).toBeUndefined();
});
test("sets artboard visibility and lock state", () => {
const state = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
);
const hidden = documentSetArtboardVisibleCommand.execute({ state }, { id: "a1", visible: false });
const locked = documentSetArtboardLockedCommand.execute({ state: hidden }, { id: "a1", locked: true });
expect(locked.document.artboards[0]?.visible).toBe(false);
expect(locked.document.artboards[0]?.locked).toBe(true);
});
test("sets layer visibility and lock state", () => {
const state = documentWithLayers([group("a", "A")]);
const hidden = documentSetLayerVisibleCommand.execute({ state }, { layerId: "a", visible: false });
const locked = documentSetLayerLockedCommand.execute({ state: hidden }, { layerId: "a", locked: true });
expect(locked.document.artboards[0]?.layers[0]?.visible).toBe(false);
expect(locked.document.artboards[0]?.layers[0]?.locked).toBe(true);
});
test("sets artboard bounds", () => {
const state = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
);
const next = documentSetArtboardBoundsCommand.execute({ state }, { id: "a1", bounds: { x: 10, y: 20, w: 640, h: 480 } });
expect(next.document.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 640, h: 480 });
});
});
function documentWithLayers(layers: Layer[]) {
return {
...createInitialAppState("Test"),
document: {
...createInitialAppState("Test").document,
artboards: [{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 }, backgroundColor: "transparent", visible: true, locked: false, layers }],
},
editor: {
...createInitialAppState("Test").editor,
selection: { artboardId: "a1", layerIds: [] },
},
};
}
function group(id: string, name: string) {
return {
id,
type: "group" as const,
name,
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [],
};
}
function raster(id: string, name: string, assetId = `${id}-asset`) {
return {
id,
type: "raster" as const,
name,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
};
}
function maskAsset() {
return { id: "mask-asset", name: "Target Mask", mimeType: "image/svg+xml", source: "mask", intrinsicSize: { w: 100, h: 100 } };
}