feat: more tests, refactored

This commit is contained in:
2026-05-24 21:05:21 +07:00
parent e7dd9420e7
commit c1f12c7201
33 changed files with 1403 additions and 334 deletions
@@ -17,7 +17,7 @@ function normalizeFaceBlur(effect: FaceBlurEffect): FaceBlurEffect {
};
}
export const faceBlurDefinition: EffectDefinition<FaceBlurEffect> = {
export const faceBlurDefinition: EffectDefinition = {
kind: "face-blur",
normalize: normalizeFaceBlur,
normalize: (effect) => normalizeFaceBlur(effect),
};
+4 -4
View File
@@ -1,9 +1,9 @@
import type { LayerEffect } from "@pien-studio/types";
import { faceBlurDefinition } from "./face-blur";
export type EffectDefinition<T extends LayerEffect = LayerEffect> = {
kind: T["kind"];
normalize: (effect: T) => T;
export type EffectDefinition = {
kind: LayerEffect["kind"];
normalize: (effect: LayerEffect) => LayerEffect;
};
const definitions = [faceBlurDefinition] as EffectDefinition[];
@@ -19,5 +19,5 @@ export function getEffectDefinition(kind: string): EffectDefinition | undefined
export function normalizeEffect(effect: LayerEffect): LayerEffect {
const def = effectRegistry.get(effect.kind);
if (!def) return effect;
return def.normalize(effect as never);
return def.normalize(effect);
}
+101 -55
View File
@@ -3,9 +3,32 @@ import {
addLayer,
createProject,
moveLayer,
removeLayer,
reorderLayer,
setEffectEnabled,
setLayerEffect,
setLayerVisible,
normalizeProject,
parseProjectFile,
serializeProjectFile,
setCanvasSize,
updateLayerTransform,
} from "./index";
import type { Layer } from "@pien-studio/types";
function makeLayer(id: string, type: Layer["type"] = "raster"): Layer {
return {
id,
type,
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
};
}
describe("editor-core", () => {
it("creates project with defaults", () => {
@@ -17,17 +40,7 @@ describe("editor-core", () => {
it("adds a new layer and updates timestamp", () => {
const project = createProject("new");
const updated = addLayer(project, {
id: "l1",
type: "raster",
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const updated = addLayer(project, makeLayer("l1"));
expect(updated.layers).toHaveLength(1);
expect(updated.layers[0]?.id).toBe("l1");
@@ -35,17 +48,7 @@ describe("editor-core", () => {
});
it("moves a layer by delta", () => {
const project = addLayer(createProject("move"), {
id: "l1",
type: "sticker",
x: 10,
y: 20,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const project = addLayer(createProject("move"), { ...makeLayer("l1", "sticker"), x: 10, y: 20 });
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
expect(moved.layers[0]?.x).toBe(25);
@@ -53,17 +56,7 @@ describe("editor-core", () => {
});
it("updates transform fields", () => {
const project = addLayer(createProject("transform"), {
id: "l1",
type: "text",
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const project = addLayer(createProject("transform"), makeLayer("l1", "text"));
const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 });
expect(updated.layers[0]?.scale).toBe(1.35);
@@ -72,30 +65,83 @@ describe("editor-core", () => {
it("reorders layer to the front", () => {
const base = createProject("reorder");
const withFirst = addLayer(base, {
id: "l1",
type: "text",
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const withSecond = addLayer(withFirst, {
id: "l2",
type: "sticker",
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const withFirst = addLayer(base, makeLayer("l1", "text"));
const withSecond = addLayer(withFirst, makeLayer("l2", "sticker"));
const reordered = reorderLayer(withSecond, "l1", 1);
expect(reordered.layers.map((layer) => layer.id)).toEqual(["l2", "l1"]);
});
it("preserves project identity for missing layers and unchanged transforms", () => {
const project = addLayer(createProject("noop"), makeLayer("l1"));
expect(removeLayer(project, "missing")).toBe(project);
expect(moveLayer(project, "l1", { dx: 0, dy: 0 })).toBe(project);
expect(updateLayerTransform(project, "l1", { x: 0, y: 0 })).toBe(project);
expect(reorderLayer(project, "l1", 0)).toBe(project);
});
it("normalizes effects before writing them to layers", () => {
const project = addLayer(createProject("effects"), makeLayer("l1"));
const updated = setLayerEffect(project, "l1", {
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 99,
regions: [{ x: Number.NaN, y: 3, width: 0, height: Number.NaN }],
});
expect(updated.layers[0]?.effects[0]).toEqual({
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 40,
regions: [{ x: 0, y: 3, width: 1, height: 1, sourceWidth: undefined, sourceHeight: undefined, censorColor: undefined }],
});
});
it("does not create changes for unchanged layer visibility or effect enabled state", () => {
const project = setLayerEffect(addLayer(createProject("visibility"), makeLayer("l1")), "l1", {
kind: "face-blur",
enabled: true,
method: "pixelate",
amount: 12,
regions: [],
});
expect(setLayerVisible(project, "l1", true)).toBe(project);
expect(setEffectEnabled(project, "l1", "face-blur", true)).toBe(project);
});
it("normalizes project geometry and layer fields", () => {
const project = addLayer(createProject("normalize"), {
...makeLayer("l1"),
width: 10.4,
height: 0,
scale: Number.NaN,
rotation: Number.NaN,
opacity: 4,
});
const normalized = normalizeProject({ ...project, canvas: { width: 0.2, height: 20.6, unit: "px" } });
expect(normalized.canvas).toEqual({ width: 1, height: 21, unit: "px" });
expect(normalized.layers[0]).toMatchObject({ width: 10, height: 1, scale: 1, rotation: 0, opacity: 1 });
});
it("serializes and parses project files with normalized projects", () => {
const project = addLayer(createProject("file"), { ...makeLayer("l1"), width: 3.8 });
const raw = serializeProjectFile(project, { checkpointCount: 2 });
const parsed = parseProjectFile(raw);
expect(JSON.parse(raw).history.checkpointCount).toBe(2);
expect(parsed.ok).toBe(true);
expect(parsed.ok ? parsed.project.layers[0]?.width : undefined).toBe(4);
expect(parseProjectFile("not json")).toEqual({ ok: false, error: "Invalid JSON" });
expect(parseProjectFile(JSON.stringify({ format: "wrong" }))).toEqual({ ok: false, error: "Invalid project format" });
});
it("sets canvas size with clamped rounded dimensions", () => {
const project = createProject("canvas");
expect(setCanvasSize(project, 0, 12.6, "cm").canvas).toEqual({ width: 1, height: 13, unit: "cm" });
});
});
+41 -34
View File
@@ -1,4 +1,5 @@
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
import { normalizeEffect } from "./effects/registry";
export type LayerFactoryOptions = {
id?: string;
@@ -26,6 +27,22 @@ function withUpdatedAt(project: Project, layers: Layer[]): Project {
return { ...project, layers, updatedAt: new Date().toISOString() };
}
function updateLayer(project: Project, layerId: string, updater: (layer: Layer) => Layer): Project {
let changed = false;
const layers = project.layers.map((layer) => {
if (layer.id !== layerId) return layer;
const nextLayer = updater(layer);
if (nextLayer !== layer) changed = true;
return nextLayer;
});
return changed ? withUpdatedAt(project, layers) : project;
}
function hasTransformPatchChange(layer: Layer, patch: TransformPatch): boolean {
return (Object.keys(patch) as (keyof TransformPatch)[]).some((key) => layer[key] !== patch[key]);
}
export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer {
return {
id: options.id ?? crypto.randomUUID(),
@@ -49,21 +66,17 @@ export function addLayer(project: Project, layer: Layer): Project {
}
export function removeLayer(project: Project, layerId: string): Project {
if (!project.layers.some((l) => l.id === layerId)) return project;
return withUpdatedAt(project, project.layers.filter((l) => l.id !== layerId));
}
export function moveLayer(project: Project, layerId: string, delta: { dx: number; dy: number }): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, x: l.x + delta.dx, y: l.y + delta.dy } : l)),
);
if (delta.dx === 0 && delta.dy === 0) return project;
return updateLayer(project, layerId, (l) => ({ ...l, x: l.x + delta.dx, y: l.y + delta.dy }));
}
export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, ...patch } : l)),
);
return updateLayer(project, layerId, (l) => (hasTransformPatchChange(l, patch) ? { ...l, ...patch } : l));
}
export function reorderLayer(project: Project, layerId: string, toIndex: number): Project {
@@ -72,43 +85,37 @@ export function reorderLayer(project: Project, layerId: string, toIndex: number)
const layers = [...project.layers];
const [picked] = layers.splice(fromIndex, 1);
if (!picked) return project;
layers.splice(Math.max(0, Math.min(toIndex, layers.length)), 0, picked);
const nextIndex = Math.max(0, Math.min(toIndex, layers.length));
if (fromIndex === nextIndex) return project;
layers.splice(nextIndex, 0, picked);
return withUpdatedAt(project, layers);
}
export function setLayerEffect(project: Project, layerId: string, effect: LayerEffect): Project {
return withUpdatedAt(
project,
project.layers.map((l) => {
if (l.id !== layerId) return l;
const idx = l.effects.findIndex((e) => e.kind === effect.kind);
const effects = idx >= 0 ? l.effects.map((e, i) => (i === idx ? effect : e)) : [...l.effects, effect];
return { ...l, effects };
}),
);
const normalizedEffect = normalizeEffect(effect);
return updateLayer(project, layerId, (l) => {
const idx = l.effects.findIndex((e) => e.kind === normalizedEffect.kind);
const effects = idx >= 0 ? l.effects.map((e, i) => (i === idx ? normalizedEffect : e)) : [...l.effects, normalizedEffect];
if (JSON.stringify(effects) === JSON.stringify(l.effects)) return l;
return { ...l, effects };
});
}
export function removeLayerEffect(project: Project, layerId: string, kind: LayerEffect["kind"]): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, effects: l.effects.filter((e) => e.kind !== kind) } : l)),
);
return updateLayer(project, layerId, (l) => {
if (!l.effects.some((e) => e.kind === kind)) return l;
return { ...l, effects: l.effects.filter((e) => e.kind !== kind) };
});
}
export function setLayerVisible(project: Project, layerId: string, visible: boolean): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, visible } : l)),
);
return updateLayer(project, layerId, (l) => (l.visible === visible ? l : { ...l, visible }));
}
export function setEffectEnabled(project: Project, layerId: string, kind: LayerEffect["kind"], enabled: boolean): Project {
return withUpdatedAt(
project,
project.layers.map((l) =>
l.id === layerId
? { ...l, effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)) }
: l,
),
);
return updateLayer(project, layerId, (l) => {
const effect = l.effects.find((e) => e.kind === kind);
if (!effect || effect.enabled === enabled) return l;
return { ...l, effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)) };
});
}