Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/canvas/__tests__/store.test.ts

Raw
import { describe, expect, it } from "vitest";
import {
	acceptElements,
	cancelDraw,
	changeScene,
	commitDraw,
	createDraw,
	documentFingerprint,
	emptyState,
	MAX_ELEMENTS,
	prepareDraw,
	restoreState,
} from "../src/store.ts";

const rectangle = (id: string, extra: Record<string, unknown> = {}) => ({
	id,
	type: "rectangle",
	x: 1,
	y: 2,
	width: 30,
	height: 40,
	...extra,
});
const snapshot = (state: unknown) => ({
	type: "custom",
	customType: "canvas",
	data: { kind: "snapshot", state },
});

describe("canvas state store", () => {
	it("validates IDs, bounds, and cross-references after all live elements are known", () => {
		expect(() =>
			acceptElements([rectangle("same"), rectangle("same")]),
		).toThrow("Duplicate");
		expect(() =>
			acceptElements([
				rectangle("arrow", {
					type: "arrow",
					startBinding: { elementId: "later" },
				}),
				rectangle("later"),
			]),
		).not.toThrow();
		expect(() =>
			acceptElements([
				rectangle("arrow", {
					type: "arrow",
					startBinding: { elementId: "missing" },
				}),
			]),
		).toThrow("Dangling");
		expect(() =>
			acceptElements([
				rectangle("dead", { isDeleted: true, frameId: "historic" }),
			]),
		).not.toThrow();
		expect(() =>
			acceptElements([rectangle("bad", { constructor: {} })]),
		).toThrow("Unsafe");
		expect(() =>
			acceptElements(
				Array.from({ length: MAX_ELEMENTS + 1 }, (_, index) =>
					rectangle(String(index)),
				),
			),
		).toThrow("1000");
	});

	it("preserves explicit addition IDs and freezes immutable single-owner preparation", () => {
		const initial = {
			...emptyState(),
			revision: 2,
			elements: [rectangle("one")],
		};
		const drawn = createDraw(initial, {
			baseRevision: 2,
			title: "add",
			additions: [rectangle("client-id")],
		});
		const draw = drawn.pendingDraw!;
		expect(draw.additions[0]?.id).toBe("client-id");
		expect(drawn.elements).toEqual(initial.elements);
		const prepared = prepareDraw(drawn, draw.id, "browser-1", draw.additions);
		expect(prepared.pendingDraw?.preparedAdditions).toEqual(draw.additions);
		expect(Object.isFrozen(prepared.pendingDraw?.preparedAdditions)).toBe(true);
		expect(Object.isFrozen(prepared.pendingDraw?.preparedAdditions?.[0])).toBe(
			true,
		);
		expect(() =>
			prepareDraw(prepared, draw.id, "browser-1", draw.additions),
		).toThrow("Stale");
		expect(drawn.pendingDraw?.preparedAdditions).toBeNull();
		expect(() =>
			prepareDraw(drawn, draw.id, "browser-2", [
				{ ...draw.additions[0], x: 999 },
			]),
		).toThrow("does not match");
	});

	it("accepts native defaults and safe labels but rejects source tampering", () => {
		const drawn = createDraw(emptyState(), {
			baseRevision: 0,
			title: "labeled",
			additions: [
				rectangle("shape", {
					text: "Source",
					strokeColor: "#123456",
					label: { text: "Hello" },
				}),
			],
		});
		const draw = drawn.pendingDraw!;
		const native = [
			{
				id: "shape",
				type: "rectangle",
				x: 1,
				y: 2,
				width: 30,
				height: 40,
				text: "Source",
				strokeColor: "#123456",
				seed: 1,
				version: 1,
				boundElements: [{ id: "label", type: "text" }],
			},

			{
				id: "label",
				type: "text",
				x: 2,
				y: 3,
				text: "Hello",
				originalText: "Hello",
				containerId: "shape",
			},
		];
		expect(() => prepareDraw(drawn, draw.id, "browser", native)).not.toThrow();
		for (const changes of [
			{ type: "ellipse" },
			{ x: 99 },
			{ text: "tampered" },
			{ strokeColor: "#000000" },
		])
			expect(() =>
				prepareDraw(drawn, draw.id, "browser", [
					{ ...native[0]!, ...changes },
					native[1]!,
				]),
			).toThrow("does not match");
	});

	it("commits exact prepared additions and requested-only updates/deletions", () => {
		const initial = {
			...emptyState(),
			revision: 2,
			elements: [rectangle("one"), rectangle("gone")],
		};
		const drawn = createDraw(initial, {
			baseRevision: 2,
			title: "edit",
			additions: [rectangle("new")],
			updates: [{ id: "one", changes: { opacity: 50 } }],
			deleteIds: ["gone"],
		});
		const draw = drawn.pendingDraw!;
		const prepared = prepareDraw(drawn, draw.id, "browser", draw.additions);
		const final = [
			rectangle("one", { opacity: 50 }),
			{ ...rectangle("gone"), isDeleted: true },
			draw.additions[0]!,
		];
		const committed = commitDraw(prepared, draw.id, "browser", 2, final);
		expect(committed.revision).toBe(3);
		expect(committed.pendingDraw).toBeNull();
		expect(() =>
			commitDraw(prepared, draw.id, "browser", 2, [
				rectangle("one", { opacity: 50 }),
				{ ...rectangle("gone"), isDeleted: true },
				{ ...draw.additions[0]!, width: 99 },
			]),
		).toThrow("additions changed");
		expect(() =>
			commitDraw(prepared, draw.id, "browser", 2, [
				rectangle("one", { opacity: 50, x: 90 }),
				{ ...rectangle("gone"), isDeleted: true },
				draw.additions[0]!,
			]),
		).toThrow("unrelated");
	});

	it("rejects inbound and outbound bound deletion, stale commits, and cancelled jobs", () => {
		const bound = {
			...emptyState(),
			elements: [
				rectangle("parent", { boundElements: [{ id: "child", type: "text" }] }),
				rectangle("child"),
			],
		};
		expect(() =>
			createDraw(bound, {
				baseRevision: 0,
				title: "delete",
				deleteIds: ["parent"],
			}),
		).toThrow("referenced");
		expect(() =>
			createDraw(bound, {
				baseRevision: 0,
				title: "delete",
				deleteIds: ["child"],
			}),
		).toThrow("referenced");
		const drawn = createDraw(
			{ ...emptyState(), elements: [rectangle("one")] },
			{
				baseRevision: 0,
				title: "update",
				updates: [{ id: "one", changes: { opacity: 40 } }],
			},
		);
		const prepared = prepareDraw(drawn, drawn.pendingDraw!.id, "browser", []);
		const cancelled = cancelDraw(prepared, drawn.pendingDraw!.id, "browser");
		expect(() =>
			commitDraw(cancelled, drawn.pendingDraw!.id, "browser", 0, [
				rectangle("one", { opacity: 40 }),
			]),
		).toThrow("Stale");
	});

	it("rejects unsafe Mermaid, requires output namespace, and preserves conversion exactly", () => {
		expect(() =>
			createDraw(emptyState(), {
				baseRevision: 0,
				title: "unsafe",
				mermaid: {
					definition: "flowchart TD\nclick A href 'https://evil.test'",
					x: 0,
					y: 0,
				},
			}),
		).toThrow("unsafe");
		const drawn = createDraw(emptyState(), {
			baseRevision: 0,
			title: "diagram",
			mermaid: { definition: "flowchart TD", x: 0, y: 0 },
		});
		const draw = drawn.pendingDraw!;
		const generated = [
			rectangle(`${draw.id}-mermaid-0`),
			rectangle(`${draw.id}-mermaid-1`, { frameId: `${draw.id}-mermaid-0` }),
		];
		expect(() =>
			prepareDraw(drawn, draw.id, "browser", [rectangle("wrong-mermaid-0")]),
		).toThrow("Mermaid element ID");
		expect(() => prepareDraw(drawn, draw.id, "browser", [])).toThrow(
			"produced no native",
		);
		const prepared = prepareDraw(drawn, draw.id, "browser", generated);
		expect(() =>
			commitDraw(prepared, draw.id, "browser", 0, [
				{ ...generated[0]!, width: 99 },
				generated[1]!,
			]),
		).toThrow("additions changed");
		expect(
			commitDraw(prepared, draw.id, "browser", 0, generated).elements,
		).toEqual(generated);
	});

	it("restores elements and revisions while ignoring historic annotation ledgers", () => {
		const restored = restoreState([
			snapshot({
				revision: 7,
				elements: [rectangle("one")],
				annotations: [{ malformed: true }],
				pendingDraw: { unsafe: true },
				messages: [{ text: "ignored" }],
			}),
		]);
		expect(restored).toEqual({
			...emptyState(),
			revision: 7,
			elements: [rectangle("one")],
		});
		expect(restored).not.toHaveProperty("annotations");
	});

	it("enforces complete state limit when preparation duplicates transient additions", () => {
		const text = "x".repeat(16_000);
		const additions = Array.from({ length: 25 }, (_, index) =>
			rectangle(`new-${index}`, { text }),
		);
		const drawn = createDraw(
			{
				...emptyState(),
				elements: Array.from({ length: 25 }, (_, index) =>
					rectangle(`old-${index}`, { text }),
				),
			},
			{ baseRevision: 0, title: "large", additions },
		);
		expect(() =>
			prepareDraw(drawn, drawn.pendingDraw!.id, "browser", additions),
		).toThrow("size limit");
	});

	it("keeps original order and rejects tombstone additions or canonical ID reuse", () => {
		const initial = {
			...emptyState(),
			elements: [rectangle("first"), rectangle("second")],
		};
		const drawn = createDraw(initial, {
			baseRevision: 0,
			title: "add",
			additions: [rectangle("third")],
		});
		const draw = drawn.pendingDraw!;
		const prepared = prepareDraw(drawn, draw.id, "browser", draw.additions);
		expect(() =>
			commitDraw(prepared, draw.id, "browser", 0, [
				draw.additions[0]!,
				rectangle("first"),
				rectangle("second"),
			]),
		).toThrow("order");
		expect(() =>
			createDraw(initial, {
				baseRevision: 0,
				title: "tombstone",
				additions: [rectangle("new", { isDeleted: true })],
			}),
		).toThrow("unique IDs");
		expect(() =>
			createDraw(initial, {
				baseRevision: 0,
				title: "reuse",
				additions: [rectangle("first")],
			}),
		).toThrow("unique IDs");
	});

	it("keeps document fingerprint semantics for scene updates", () => {
		const state = {
			...emptyState(),
			revision: 1,
			elements: [rectangle("one", { version: 1, updated: 1 })],
		};
		expect(
			changeScene(state, 1, [rectangle("one", { version: 2, updated: 2 })]),
		).toBe(state);
		expect(documentFingerprint([rectangle("one"), rectangle("two")])).not.toBe(
			documentFingerprint([rectangle("two"), rectangle("one")]),
		);
	});
});