repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/canvas/__tests__/store.test.ts
Rawimport { 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")]),
);
});
});