488 lines
11 KiB
TypeScript
488 lines
11 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
findClosestPointOnCustomMaskSegment,
|
|
getCustomMaskClosedStateAfterPointRemoval,
|
|
insertPointIntoCustomMaskSegment,
|
|
removeCustomMaskPoints,
|
|
} from "@/masks/custom-path";
|
|
import {
|
|
appendPointToCustomMask,
|
|
customMaskDefinition,
|
|
insertPointOnCustomMaskSegment,
|
|
} from "@/masks/definitions/custom";
|
|
import { getSplitMaskStrokeSegment } from "@/masks/definitions/split";
|
|
import { textMaskDefinition } from "@/masks/definitions/text";
|
|
import { getMaskSnapGeometry } from "@/masks/geometry";
|
|
import { snapBoxMaskInteraction, snapSplitMaskInteraction } from "@/masks/snap";
|
|
import type { ElementBounds } from "@/preview/element-bounds";
|
|
import type {
|
|
CustomMaskParams,
|
|
RectangleMaskParams,
|
|
SplitMaskParams,
|
|
TextMaskParams,
|
|
} from "@/masks/types";
|
|
|
|
const bounds: ElementBounds = {
|
|
cx: 200,
|
|
cy: 150,
|
|
width: 200,
|
|
height: 100,
|
|
rotation: 0,
|
|
};
|
|
|
|
const canvasSize = {
|
|
width: 400,
|
|
height: 300,
|
|
};
|
|
|
|
const snapThreshold = {
|
|
x: 8,
|
|
y: 8,
|
|
};
|
|
|
|
function buildSplitParams(
|
|
overrides: Partial<SplitMaskParams> = {},
|
|
): SplitMaskParams {
|
|
return {
|
|
feather: 0,
|
|
inverted: false,
|
|
strokeColor: "#ffffff",
|
|
strokeWidth: 0,
|
|
strokeAlign: "center",
|
|
centerX: 0,
|
|
centerY: 0,
|
|
rotation: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildRectangleParams(
|
|
overrides: Partial<RectangleMaskParams> = {},
|
|
): RectangleMaskParams {
|
|
return {
|
|
feather: 0,
|
|
inverted: false,
|
|
strokeColor: "#ffffff",
|
|
strokeWidth: 0,
|
|
strokeAlign: "center",
|
|
centerX: 0,
|
|
centerY: 0,
|
|
width: 0.4,
|
|
height: 0.2,
|
|
rotation: 0,
|
|
scale: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildTextMaskParams(
|
|
overrides: Partial<TextMaskParams> = {},
|
|
): TextMaskParams {
|
|
return {
|
|
feather: 0,
|
|
inverted: false,
|
|
strokeColor: "#ffffff",
|
|
strokeWidth: 0,
|
|
strokeAlign: "center",
|
|
content: "Mask",
|
|
fontSize: 15,
|
|
fontFamily: "Arial",
|
|
fontWeight: "normal",
|
|
fontStyle: "normal",
|
|
textDecoration: "none",
|
|
letterSpacing: 0,
|
|
lineHeight: 1.2,
|
|
centerX: 0,
|
|
centerY: 0,
|
|
rotation: 0,
|
|
scale: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildCustomMaskParams(
|
|
overrides: Partial<CustomMaskParams> = {},
|
|
): CustomMaskParams {
|
|
return {
|
|
feather: 0,
|
|
inverted: false,
|
|
strokeColor: "#ffffff",
|
|
strokeWidth: 0,
|
|
strokeAlign: "center",
|
|
path: [
|
|
{
|
|
id: "a",
|
|
x: -0.2,
|
|
y: -0.1,
|
|
inX: 0,
|
|
inY: 0,
|
|
outX: 0,
|
|
outY: 0,
|
|
},
|
|
{
|
|
id: "b",
|
|
x: 0.2,
|
|
y: -0.1,
|
|
inX: 0,
|
|
inY: 0,
|
|
outX: 0,
|
|
outY: 0,
|
|
},
|
|
{
|
|
id: "c",
|
|
x: 0,
|
|
y: 0.2,
|
|
inX: 0,
|
|
inY: 0,
|
|
outX: 0,
|
|
outY: 0,
|
|
},
|
|
],
|
|
closed: true,
|
|
centerX: 0,
|
|
centerY: 0,
|
|
rotation: 0,
|
|
scale: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function sortSegment(
|
|
segment: [{ x: number; y: number }, { x: number; y: number }],
|
|
): [{ x: number; y: number }, { x: number; y: number }] {
|
|
const [first, second] = segment;
|
|
return first.x < second.x || (first.x === second.x && first.y <= second.y)
|
|
? [first, second]
|
|
: [second, first];
|
|
}
|
|
|
|
describe("mask geometry", () => {
|
|
test("resolves split mask center from centerX and centerY", () => {
|
|
expect(
|
|
getMaskSnapGeometry({
|
|
params: buildSplitParams({
|
|
centerX: 0.25,
|
|
centerY: -0.5,
|
|
rotation: 45,
|
|
}),
|
|
bounds,
|
|
}),
|
|
).toEqual({
|
|
position: { x: 50, y: -50 },
|
|
size: { width: 0, height: 0 },
|
|
rotation: 45,
|
|
});
|
|
});
|
|
|
|
test("resolves box mask center and size from centerX and centerY", () => {
|
|
expect(
|
|
getMaskSnapGeometry({
|
|
params: buildRectangleParams({
|
|
centerX: -0.25,
|
|
centerY: 0.5,
|
|
width: 0.5,
|
|
height: 0.6,
|
|
rotation: 30,
|
|
}),
|
|
bounds,
|
|
}),
|
|
).toEqual({
|
|
position: { x: -50, y: 50 },
|
|
size: { width: 100, height: 60 },
|
|
rotation: 30,
|
|
});
|
|
});
|
|
|
|
test("returns a vertical split stroke segment for rotation 0", () => {
|
|
const segment = getSplitMaskStrokeSegment({
|
|
resolvedParams: buildSplitParams(),
|
|
width: bounds.width,
|
|
height: bounds.height,
|
|
});
|
|
|
|
expect(segment).not.toBeNull();
|
|
if (!segment) {
|
|
throw new Error("Expected split stroke segment for rotation 0");
|
|
}
|
|
expect(sortSegment(segment)).toEqual([
|
|
{ x: bounds.width / 2, y: 0 },
|
|
{ x: bounds.width / 2, y: bounds.height },
|
|
]);
|
|
});
|
|
|
|
test("returns a horizontal split stroke segment for rotation 90", () => {
|
|
const segment = getSplitMaskStrokeSegment({
|
|
resolvedParams: buildSplitParams({ rotation: 90 }),
|
|
width: bounds.width,
|
|
height: bounds.height,
|
|
});
|
|
|
|
expect(segment).not.toBeNull();
|
|
if (!segment) {
|
|
throw new Error("Expected split stroke segment for rotation 90");
|
|
}
|
|
expect(sortSegment(segment)).toEqual([
|
|
{ x: 0, y: bounds.height / 2 },
|
|
{ x: bounds.width, y: bounds.height / 2 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("mask snapping", () => {
|
|
test("snaps split mask movement using the shared position pipeline", () => {
|
|
const result = snapSplitMaskInteraction({
|
|
handleId: { kind: "position" },
|
|
startParams: buildSplitParams({
|
|
centerX: 0.03,
|
|
centerY: -0.04,
|
|
}),
|
|
proposedParams: buildSplitParams({
|
|
centerX: 0.03,
|
|
centerY: -0.04,
|
|
}),
|
|
bounds,
|
|
canvasSize,
|
|
snapThreshold,
|
|
});
|
|
|
|
expect(result.params.centerX).toBe(0);
|
|
expect(result.params.centerY).toBe(0);
|
|
expect(result.activeLines).toEqual([
|
|
{ type: "vertical", position: 0 },
|
|
{ type: "horizontal", position: 0 },
|
|
]);
|
|
});
|
|
|
|
test("snaps box mask movement against element center and edges", () => {
|
|
const result = snapBoxMaskInteraction({
|
|
handleId: { kind: "position" },
|
|
startParams: buildRectangleParams(),
|
|
proposedParams: buildRectangleParams({
|
|
centerX: 0.29,
|
|
centerY: 0.03,
|
|
}),
|
|
bounds,
|
|
canvasSize,
|
|
snapThreshold,
|
|
});
|
|
|
|
expect(result.params.centerX).toBeCloseTo(0.3);
|
|
expect(result.params.centerY).toBe(0);
|
|
expect(result.activeLines).toEqual([
|
|
{ type: "vertical", position: 100 },
|
|
{ type: "horizontal", position: 0 },
|
|
]);
|
|
});
|
|
|
|
test("snaps mask rotation through the shared rotation path", () => {
|
|
const result = snapBoxMaskInteraction({
|
|
handleId: { kind: "rotation" },
|
|
startParams: buildRectangleParams(),
|
|
proposedParams: buildRectangleParams({
|
|
rotation: 88,
|
|
}),
|
|
bounds,
|
|
canvasSize,
|
|
snapThreshold,
|
|
});
|
|
|
|
expect(result.params.rotation).toBe(90);
|
|
expect(result.activeLines).toEqual([]);
|
|
});
|
|
|
|
test("snaps edge resize for box masks", () => {
|
|
const result = snapBoxMaskInteraction({
|
|
handleId: { kind: "edge", side: "right" },
|
|
startParams: buildRectangleParams(),
|
|
proposedParams: buildRectangleParams({
|
|
width: 0.98,
|
|
}),
|
|
bounds,
|
|
canvasSize,
|
|
snapThreshold,
|
|
});
|
|
|
|
expect(result.params.width).toBe(1);
|
|
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
|
});
|
|
|
|
test("snaps corner resize for box masks", () => {
|
|
const result = snapBoxMaskInteraction({
|
|
handleId: { kind: "corner", corner: { x: "right", y: "bottom" } },
|
|
startParams: buildRectangleParams(),
|
|
proposedParams: buildRectangleParams({
|
|
width: 0.99,
|
|
height: 0.495,
|
|
}),
|
|
bounds,
|
|
canvasSize,
|
|
snapThreshold,
|
|
});
|
|
|
|
expect(result.params.width).toBe(1);
|
|
expect(result.params.height).toBe(0.5);
|
|
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
|
});
|
|
|
|
test("snaps text mask movement using intrinsic text bounds", () => {
|
|
const params = buildTextMaskParams({
|
|
centerX: 0.03,
|
|
centerY: -0.04,
|
|
});
|
|
const result = textMaskDefinition.interaction.snap?.({
|
|
handleId: { kind: "position" },
|
|
startParams: params,
|
|
proposedParams: params,
|
|
bounds,
|
|
canvasSize,
|
|
snapThreshold,
|
|
});
|
|
|
|
expect(result?.params.centerX).toBe(0);
|
|
expect(result?.params.centerY).toBe(0);
|
|
expect(result?.activeLines).toEqual([
|
|
{ type: "vertical", position: 0 },
|
|
{ type: "horizontal", position: 0 },
|
|
]);
|
|
});
|
|
|
|
test("snaps custom mask movement using path geometry bounds", () => {
|
|
const params = buildCustomMaskParams({
|
|
centerX: 0.03,
|
|
centerY: -0.04,
|
|
});
|
|
const result = customMaskDefinition.interaction.snap?.({
|
|
handleId: { kind: "position" },
|
|
startParams: params,
|
|
proposedParams: params,
|
|
bounds,
|
|
canvasSize,
|
|
snapThreshold,
|
|
});
|
|
|
|
expect(result?.params.centerX).toBe(0);
|
|
expect(result?.params.centerY).toBe(0);
|
|
expect(result?.activeLines).toEqual([
|
|
{ type: "vertical", position: 0 },
|
|
{ type: "horizontal", position: 0 },
|
|
]);
|
|
});
|
|
|
|
test("marks blank text masks inactive", () => {
|
|
expect(
|
|
textMaskDefinition.isActive?.(
|
|
buildTextMaskParams({
|
|
content: " ",
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("custom mask creation", () => {
|
|
test("anchors the first point at the click position", () => {
|
|
const params = buildCustomMaskParams({
|
|
path: [],
|
|
closed: false,
|
|
});
|
|
const next = appendPointToCustomMask({
|
|
params,
|
|
canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
|
|
bounds,
|
|
});
|
|
|
|
expect(next.centerX).toBeCloseTo(0.1);
|
|
expect(next.centerY).toBeCloseTo(-0.1);
|
|
expect(next.rotation).toBe(0);
|
|
expect(next.scale).toBe(1);
|
|
expect(next.path).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("custom mask point deletion", () => {
|
|
test("removes the selected points by id", () => {
|
|
const points = buildCustomMaskParams().path;
|
|
const nextPoints = removeCustomMaskPoints({
|
|
points,
|
|
pointIds: ["b"],
|
|
});
|
|
|
|
expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]);
|
|
});
|
|
|
|
test("reopens a closed path once fewer than three points remain", () => {
|
|
const points = buildCustomMaskParams().path;
|
|
const nextPoints = removeCustomMaskPoints({
|
|
points,
|
|
pointIds: ["c"],
|
|
});
|
|
|
|
expect(
|
|
getCustomMaskClosedStateAfterPointRemoval({
|
|
wasClosed: true,
|
|
remainingPointCount: nextPoints.length,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("custom mask point insertion", () => {
|
|
test("finds the closest point on the clicked segment", () => {
|
|
const params = buildCustomMaskParams();
|
|
const points = params.path;
|
|
const closestPoint = findClosestPointOnCustomMaskSegment({
|
|
points,
|
|
segmentIndex: 0,
|
|
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
|
|
centerX: params.centerX,
|
|
centerY: params.centerY,
|
|
rotation: params.rotation,
|
|
scale: params.scale,
|
|
bounds,
|
|
closed: params.closed,
|
|
});
|
|
|
|
expect(closestPoint).not.toBeNull();
|
|
expect(closestPoint?.t).toBeCloseTo(0.5, 1);
|
|
expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4);
|
|
expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4);
|
|
});
|
|
|
|
test("splits a segment into two segments at the insertion point", () => {
|
|
const points = buildCustomMaskParams().path;
|
|
const nextPoints = insertPointIntoCustomMaskSegment({
|
|
points,
|
|
segmentIndex: 0,
|
|
pointId: "new",
|
|
t: 0.5,
|
|
closed: true,
|
|
});
|
|
|
|
expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]);
|
|
expect(nextPoints[1]).toMatchObject({
|
|
id: "new",
|
|
x: 0,
|
|
y: -0.1,
|
|
inX: 0,
|
|
inY: 0,
|
|
outX: 0,
|
|
outY: 0,
|
|
});
|
|
});
|
|
|
|
test("builds updated custom mask params for a clicked segment", () => {
|
|
const result = insertPointOnCustomMaskSegment({
|
|
params: buildCustomMaskParams(),
|
|
segmentIndex: 0,
|
|
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
|
|
bounds,
|
|
pointId: "new",
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
const nextPoints = result?.params.path ?? [];
|
|
expect(nextPoints).toHaveLength(4);
|
|
expect(nextPoints.some((point) => point.id === "new")).toBe(true);
|
|
});
|
|
});
|