169 lines
5.8 KiB
TypeScript
169 lines
5.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
arcEndpointToCenter,
|
||
describePathCommand,
|
||
movePathHandle,
|
||
parsePathData,
|
||
pathHandles,
|
||
pointOnArc,
|
||
reversePath,
|
||
serializePathData,
|
||
splitSegment,
|
||
transformPath,
|
||
} from "../../src/domain/path";
|
||
|
||
describe("application-owned SVG path model", () => {
|
||
it("normalizes every command family, relatives, repeats and shorthand controls", () => {
|
||
const model = parsePathData(
|
||
"m 10 10 5 5 h 10 v 10 c 1 2 3 4 5 6 s 7 8 9 10 q 2 3 4 5 t 6 7 a 8 9 30 0 1 10 11 z",
|
||
);
|
||
expect(model.segments.map((segment) => segment.kind)).toEqual([
|
||
"M",
|
||
"L",
|
||
"L",
|
||
"L",
|
||
"C",
|
||
"C",
|
||
"Q",
|
||
"Q",
|
||
"A",
|
||
"Z",
|
||
]);
|
||
const firstCubic = model.segments[4];
|
||
const smoothCubic = model.segments[5];
|
||
expect(firstCubic?.kind).toBe("C");
|
||
expect(smoothCubic?.kind).toBe("C");
|
||
if (firstCubic?.kind === "C" && smoothCubic?.kind === "C") {
|
||
expect(smoothCubic.control1).toEqual({
|
||
x: 2 * smoothCubic.from.x - firstCubic.control2.x,
|
||
y: 2 * smoothCubic.from.y - firstCubic.control2.y,
|
||
});
|
||
expect(smoothCubic).toMatchObject({
|
||
derivedControl1: true,
|
||
sourceForm: { command: "s", relative: true },
|
||
});
|
||
const derived = pathHandles(model).find(
|
||
(handle) => handle.segmentIndex === 5 && handle.role === "control-1",
|
||
)!;
|
||
expect(derived.derived).toBe(true);
|
||
const explicit = movePathHandle(model, derived, {
|
||
x: derived.point.x + 1,
|
||
y: derived.point.y,
|
||
});
|
||
expect(explicit.segments[5]).toMatchObject({ derivedControl1: false });
|
||
}
|
||
expect(model.segments[7]).toMatchObject({
|
||
derivedControl: true,
|
||
sourceForm: { command: "t" },
|
||
});
|
||
expect(
|
||
model.segments.every(
|
||
({ sourceForm }) =>
|
||
sourceForm !== undefined &&
|
||
sourceForm.sourceRange.to > sourceForm.sourceRange.from,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("supports compact numbers, exponent notation and packed arc flags", () => {
|
||
const model = parsePathData("M.5.6L10-5e-1A5 6 0 0110 20");
|
||
expect(model.segments).toHaveLength(3);
|
||
expect(model.segments[0]).toMatchObject({ to: { x: 0.5, y: 0.6 } });
|
||
expect(model.segments[1]).toMatchObject({ to: { x: 10, y: -0.5 } });
|
||
expect(model.segments[2]).toMatchObject({
|
||
kind: "A",
|
||
largeArc: false,
|
||
sweep: true,
|
||
to: { x: 10, y: 20 },
|
||
});
|
||
});
|
||
|
||
it("round-trips normalized geometry and rejects malformed data", () => {
|
||
const model = parsePathData(
|
||
"M0 0 C1 2 3 4 5 6 Q7 8 9 10 A4 3 20 1 0 12 13 Z",
|
||
);
|
||
expect(serializePathData(parsePathData(serializePathData(model)))).toBe(
|
||
serializePathData(model),
|
||
);
|
||
expect(() => parsePathData("L 1 2")).toThrow(/begin with a moveto/u);
|
||
expect(() => parsePathData("M 0 0 A 5 5 0 2 0 10 10")).toThrow(
|
||
/flag must be 0 or 1/u,
|
||
);
|
||
expect(() => parsePathData("M 0,")).toThrow(/comma/iu);
|
||
expect(() => parsePathData("M0 0 1 1 2 2", 2)).toThrow(/segment limit/u);
|
||
});
|
||
|
||
it("splits cubic geometry with de Casteljau and reverses arc sweep", () => {
|
||
const cubic = parsePathData("M0 0 C10 0 10 10 20 10");
|
||
const split = splitSegment(cubic, 1, 0.5);
|
||
expect(split.segments).toHaveLength(3);
|
||
expect(split.segments[1]).toMatchObject({ kind: "C", to: { x: 10, y: 5 } });
|
||
expect(split.segments[2]).toMatchObject({
|
||
kind: "C",
|
||
from: { x: 10, y: 5 },
|
||
});
|
||
const arc = parsePathData("M0 0 A10 5 20 0 1 20 0");
|
||
const reversed = reversePath(arc);
|
||
expect(reversed.segments[1]).toMatchObject({ kind: "A", sweep: false });
|
||
expect(serializePathData(reversePath(reversed))).toBe(
|
||
serializePathData(arc),
|
||
);
|
||
});
|
||
|
||
it("derives arc center/radius controls and moves path handles", () => {
|
||
const model = parsePathData("M0 0 A4 3 30 0 1 12 0");
|
||
const segment = model.segments[1];
|
||
expect(segment?.kind).toBe("A");
|
||
if (segment?.kind !== "A") return;
|
||
const center = arcEndpointToCenter(segment)!;
|
||
expect(pointOnArc(center, center.startAngle).x).toBeCloseTo(segment.from.x);
|
||
const anchor = pathHandles(model).find(
|
||
(handle) => handle.role === "anchor" && handle.segmentIndex === 1,
|
||
)!;
|
||
expect(
|
||
movePathHandle(model, anchor, { x: 14, y: 2 }).segments[1],
|
||
).toMatchObject({ to: { x: 14, y: 2 } });
|
||
});
|
||
|
||
it("bakes nonsingular affine matrices and flips arc sweep under reflection", () => {
|
||
const model = parsePathData("M0 0 A10 5 20 0 1 20 0");
|
||
const transformed = transformPath(model, {
|
||
a: -2,
|
||
b: 0.5,
|
||
c: 0.25,
|
||
d: 3,
|
||
e: 4,
|
||
f: 8,
|
||
});
|
||
expect(transformed.segments[1]).toMatchObject({ kind: "A", sweep: false });
|
||
expect(() =>
|
||
transformPath(model, { a: 1, b: 0, c: 0, d: 0, e: 0, f: 0 }),
|
||
).toThrow(/singular/u);
|
||
});
|
||
it("describes source form, endpoints, controls, derived shorthand and arc flags", () => {
|
||
const source = "m 1 2 3 4 s 5 6 7 8 t 9 10 a 11 12 30 1 0 13 14";
|
||
const segments = parsePathData(source).segments;
|
||
|
||
expect(describePathCommand(segments[1]!, source)).toMatchObject({
|
||
sourceCommand: "m",
|
||
normalizedCommand: "L",
|
||
sourceFragment: "3 4",
|
||
form: "relative · implicit repeat",
|
||
endpoint: "4, 6",
|
||
});
|
||
expect(describePathCommand(segments[2]!, source).details).toEqual([
|
||
{ label: "C1", value: "4, 6", derived: true },
|
||
{ label: "C2", value: "9, 12", derived: false },
|
||
]);
|
||
expect(describePathCommand(segments[3]!, source).details[0]).toMatchObject({
|
||
label: "C",
|
||
derived: true,
|
||
});
|
||
expect(describePathCommand(segments[4]!, source).details).toEqual([
|
||
{ label: "Radii", value: "11 × 12", derived: false },
|
||
{ label: "Rotation", value: "30°", derived: false },
|
||
{ label: "Flags", value: "large 1 · sweep 0", derived: false },
|
||
]);
|
||
});
|
||
});
|