mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: more tests, refactored
This commit is contained in:
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user