From 3e8a16ec2672ad8c0b8d6f592ee54c25752b274e Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 21 Dec 2025 12:52:49 -0600 Subject: [PATCH] feat: data model --- TODO.txt | 72 +- eslint.config.js | 5 +- packages/core/package.json | 1 + packages/core/src/model.ts | 264 +++++++ packages/core/tests/model.test.ts | 1084 +++++++++++++++++++++++++++++ pnpm-lock.yaml | 13 +- 6 files changed, 1407 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/model.ts create mode 100644 packages/core/tests/model.test.ts diff --git a/TODO.txt b/TODO.txt index 59b25b2..c56c096 100644 --- a/TODO.txt +++ b/TODO.txt @@ -81,21 +81,21 @@ Tests (/packages/core/tests/camera.test.ts): Goal: define the minimal data model that can represent a drawing. Records & ID (/packages/core/src/model): -[ ] Implement createId(prefix) -> uuid (v4) -[ ] Define PageRecord { id, name, shapeIds: string[] } -[ ] Define ShapeRecord base: +[x] Implement createId(prefix) -> uuid (v4) +[x] Define PageRecord { id, name, shapeIds: string[] } +[x] Define ShapeRecord base: - id, type, pageId - x, y, rot - props: object (type-specific) -[ ] Define shape types (minimal): +[x] Define shape types (minimal): - rect: { w, h, fill, stroke, radius } - ellipse: { w, h, fill, stroke } - line: { a: Vec2, b: Vec2, stroke, width } - arrow: { a: Vec2, b: Vec2, stroke, width } - text: { text, fontSize, fontFamily, color, w? } -[ ] Define BindingRecord (for arrow endpoints): +[x] Define BindingRecord (for arrow endpoints): - id, type: "arrow-end" - fromShapeId (arrow id) - toShapeId (target shape id) @@ -103,8 +103,7 @@ Records & ID (/packages/core/src/model): - anchor: e.g. { kind: "center" } for v0 Validation: -[ ] validateDoc(doc) -> { ok | errors[] } -[ ] Add test: invalid binding to missing shape returns error +[x] validateDoc(doc) -> { ok | errors[] } (DoD): - You can serialize a doc with a page + 1 shape to JSON and validate it. @@ -113,35 +112,50 @@ Validation: 4. Milestone D: Store + selectors (reactive core) *wb-D* ============================================================================== -Goal: a fast, deterministic state container for the editor. +Goal: a fast, deterministic state container for the editor using RxJS -Store (/packages/core/src/store): -[ ] Define EditorState: - - doc (pages, shapes, bindings) - - ui: { currentPageId, selectionIds[], toolId } - - camera - -[ ] Implement Store with: - - getState() - - setState(updater) - - subscribe(listener) -> unsubscribe +Store (/packages/core/src/store) - RxJS + SvelteKit (runes) friendly -[ ] Implement selectors (pure functions): +Core types: +[ ] Define EditorState: + - doc: { pages, shapes, bindings } + - ui: { currentPageId, selectionIds: string[], toolId: ToolId } + - camera: { x, y, zoom } + +RxJS store (BehaviorSubject-backed): +[ ] Implement createEditorStore(initial: EditorState) that exposes: + - state$: Observable (read stream) + - getState(): EditorState (sync snapshot) + - setState(updater: (s) => s): void (mutation API) + - subscribe(listener): () => void (Svelte-compatible subscribe) + - select(selector, eq?): Observable (derived streams) + + Notes: + - Use BehaviorSubject so new subscribers immediately get the current value. + - subscribe must return an unsubscribe function. + +Selectors (pure functions, no RxJS): +[ ] Implement selectors in /packages/core/src/store/selectors.ts: - getCurrentPage(state) - getShapesOnCurrentPage(state) - getSelectedShapes(state) -[ ] Implement invariants: - - selectionIds only reference existing shapes - - currentPageId must exist +Invariants (pick “repair” and test it): +[ ] Implement enforceInvariants(state): EditorState (repair strategy): + - selectionIds := selectionIds filtered to existing shapes + - currentPageId must exist: + - if missing, set to first existing page + - if no pages exist, create a default page and set it +[ ] Ensure setState always runs enforceInvariants before publishing next state -Tests: -[ ] subscribe fires exactly once per setState -[ ] invariants enforced (reject or repair, pick one and test it) +Tests (vitest): +[ ] subscribe immediately receives current state upon subscription (BehaviorSubject behavior) +[ ] subscribe fires exactly once per setState call +[ ] invariants are enforced on any update (selection filtered, page fixed/created) (DoD): -- Renderer can subscribe and redraw on any state change. - +- Renderer can subscribe to state$ (or subscribe()) and redraw on any change. +- SvelteKit can bridge to runes with $effect unsubscribe cleanup. ============================================================================== 5. Milestone E: Canvas renderer (read-only) *wb-E* @@ -336,7 +350,7 @@ Live update: Tests: [ ] moving bound shape changes resolved endpoint -[ ] binding to missing target is ignored (or removed)—pick one and test +[ ] binding to missing target is ignored (or removed)-pick one and test (DoD): - Arrows remain connected to moved shapes (center-to-center is fine for v0). @@ -429,7 +443,7 @@ Goal: export drawings as shareable artifacts. [ ] Implement exportSelectionToPNG (render selection bounds) [ ] Implement SVG export for basic shapes: - rect/ellipse/line/arrow/text - - camera transform baked into output or removed—pick one and document + - camera transform baked into output or removed-pick one and document Tests: [ ] exported SVG parses and contains expected elements diff --git a/eslint.config.js b/eslint.config.js index 6cd6dec..eb5c585 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,6 +10,9 @@ export default defineConfig( tseslint.configs.recommended, eslintPluginUnicorn.configs.recommended, [{ - rules: { "unicorn/no-null": "off", "unicorn/prevent-abbreviations": ["error", { "replacements": { "i": false } }] }, + rules: { + "unicorn/no-null": "off", + "unicorn/prevent-abbreviations": ["error", { "replacements": { "i": false, "props": false, "doc": false } }], + }, }], ); diff --git a/packages/core/package.json b/packages/core/package.json index efc4679..2ed4e0f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -38,6 +38,7 @@ "vitest": "^4.0.16" }, "dependencies": { + "rxjs": "^7.8.2", "uuid": "^13.0.0" } } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts new file mode 100644 index 0000000..ff37f43 --- /dev/null +++ b/packages/core/src/model.ts @@ -0,0 +1,264 @@ +import { v4 } from "uuid"; +import type { Vec2 } from "./math"; +/** + * Generate a unique ID with an optional prefix + * @param prefix - Optional prefix for the ID (e.g., 'shape', 'page', 'binding') + * @returns A unique ID string (UUID v4 format with prefix) + */ +export function createId(prefix?: string): string { + const id = v4(); + return prefix ? `${prefix}:${id}` : id; +} + +export type PageRecord = { id: string; name: string; shapeIds: string[] }; + +export const PageRecord = { + /** + * Create a new page record + */ + create(name: string, id?: string): PageRecord { + return { id: id ?? createId("page"), name, shapeIds: [] }; + }, + + /** + * Clone a page record + */ + clone(page: PageRecord): PageRecord { + return { id: page.id, name: page.name, shapeIds: [...page.shapeIds] }; + }, +}; + +export type RectProps = { w: number; h: number; fill: string; stroke: string; radius: number }; +export type EllipseProps = { w: number; h: number; fill: string; stroke: string }; +export type LineProps = { a: Vec2; b: Vec2; stroke: string; width: number }; +export type ArrowProps = { a: Vec2; b: Vec2; stroke: string; width: number }; +export type TextProps = { text: string; fontSize: number; fontFamily: string; color: string; w?: number }; + +export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text"; + +export type BaseShape = { id: string; type: ShapeType; pageId: string; x: number; y: number; rot: number }; +export type RectShape = BaseShape & { type: "rect"; props: RectProps }; +export type EllipseShape = BaseShape & { type: "ellipse"; props: EllipseProps }; +export type LineShape = BaseShape & { type: "line"; props: LineProps }; +export type ArrowShape = BaseShape & { type: "arrow"; props: ArrowProps }; +export type TextShape = BaseShape & { type: "text"; props: TextProps }; + +export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape; + +export const ShapeRecord = { + /** + * Create a rectangle shape + */ + createRect(pageId: string, x: number, y: number, properties: RectProps, id?: string): RectShape { + return { id: id ?? createId("shape"), type: "rect", pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Create an ellipse shape + */ + createEllipse(pageId: string, x: number, y: number, properties: EllipseProps, id?: string): EllipseShape { + return { id: id ?? createId("shape"), type: "ellipse", pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Create a line shape + */ + createLine(pageId: string, x: number, y: number, properties: LineProps, id?: string): LineShape { + return { id: id ?? createId("shape"), type: "line", pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Create an arrow shape + */ + createArrow(pageId: string, x: number, y: number, properties: ArrowProps, id?: string): ArrowShape { + return { id: id ?? createId("shape"), type: "arrow", pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Create a text shape + */ + createText(pageId: string, x: number, y: number, properties: TextProps, id?: string): TextShape { + return { id: id ?? createId("shape"), type: "text", pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Clone a shape record + */ + clone(shape: ShapeRecord): ShapeRecord { + return { ...shape, props: { ...shape.props } } as ShapeRecord; + }, +}; + +export type BindingType = "arrow-end"; +export type BindingHandle = "start" | "end"; + +export type BindingAnchor = { + // TODO: 'edge', 'corner', etc. + kind: "center"; +}; + +export type BindingRecord = { + id: string; + type: BindingType; + fromShapeId: string; + toShapeId: string; + handle: BindingHandle; + anchor: BindingAnchor; +}; + +export const BindingRecord = { + /** + * Create a binding record for arrow endpoints + */ + create( + fromShapeId: string, + toShapeId: string, + handle: BindingHandle, + anchor?: BindingAnchor, + id?: string, + ): BindingRecord { + if (!anchor) { + anchor = { kind: "center" }; + } + return { id: id ?? createId("binding"), type: "arrow-end", fromShapeId, toShapeId, handle, anchor }; + }, + + /** + * Clone a binding record + */ + clone(binding: BindingRecord): BindingRecord { + return { ...binding, anchor: { ...binding.anchor } }; + }, +}; + +export type Document = { + pages: Record; + shapes: Record; + bindings: Record; +}; + +export const Document = { + /** + * Create an empty document + */ + create(): Document { + return { pages: {}, shapes: {}, bindings: {} }; + }, + + /** + * Clone a document + */ + clone(document: Document): Document { + return { + pages: Object.fromEntries(Object.entries(document.pages).map(([id, page]) => [id, PageRecord.clone(page)])), + shapes: Object.fromEntries(Object.entries(document.shapes).map(([id, shape]) => [id, ShapeRecord.clone(shape)])), + bindings: Object.fromEntries( + Object.entries(document.bindings).map(([id, binding]) => [id, BindingRecord.clone(binding)]), + ), + }; + }, +}; + +export type ValidationResult = { ok: true } | { ok: false; errors: string[] }; + +/** + * Validate a document for consistency and referential integrity + * @param doc - The document to validate + * @returns ValidationResult with ok status and any errors found + */ +export function validateDoc(document: Document): ValidationResult { + const errors: string[] = []; + + if (Object.keys(document.pages).length === 0 && Object.keys(document.shapes).length > 0) { + errors.push("Document has shapes but no pages"); + } + + for (const [shapeId, shape] of Object.entries(document.shapes)) { + if (shape.id !== shapeId) { + errors.push(`Shape key '${shapeId}' does not match shape.id '${shape.id}'`); + } + + if (!document.pages[shape.pageId]) { + errors.push(`Shape '${shapeId}' references non-existent page '${shape.pageId}'`); + } + + const page = document.pages[shape.pageId]; + if (page && !page.shapeIds.includes(shapeId)) { + errors.push(`Shape '${shapeId}' not listed in page '${shape.pageId}' shapeIds`); + } + + switch (shape.type) { + case "rect": { + if (shape.props.w < 0) errors.push(`Rect shape '${shapeId}' has negative width`); + if (shape.props.h < 0) errors.push(`Rect shape '${shapeId}' has negative height`); + if (shape.props.radius < 0) errors.push(`Rect shape '${shapeId}' has negative radius`); + + break; + } + case "ellipse": { + if (shape.props.w < 0) errors.push(`Ellipse shape '${shapeId}' has negative width`); + if (shape.props.h < 0) errors.push(`Ellipse shape '${shapeId}' has negative height`); + + break; + } + case "line": + case "arrow": { + if (shape.props.width < 0) errors.push(`${shape.type} shape '${shapeId}' has negative width`); + + break; + } + case "text": { + if (shape.props.fontSize <= 0) errors.push(`Text shape '${shapeId}' has invalid fontSize`); + if (shape.props.w !== undefined && shape.props.w < 0) { + errors.push(`Text shape '${shapeId}' has negative width`); + } + + break; + } + } + } + + for (const [pageId, page] of Object.entries(document.pages)) { + if (page.id !== pageId) { + errors.push(`Page key '${pageId}' does not match page.id '${page.id}'`); + } + + for (const shapeId of page.shapeIds) { + if (!document.shapes[shapeId]) { + errors.push(`Page '${pageId}' references non-existent shape '${shapeId}'`); + } + } + + const uniqueIds = new Set(page.shapeIds); + if (uniqueIds.size !== page.shapeIds.length) { + errors.push(`Page '${pageId}' has duplicate shape IDs`); + } + } + + for (const [bindingId, binding] of Object.entries(document.bindings)) { + if (binding.id !== bindingId) { + errors.push(`Binding key '${bindingId}' does not match binding.id '${binding.id}'`); + } + + const fromShape = document.shapes[binding.fromShapeId]; + if (!fromShape) { + errors.push(`Binding '${bindingId}' references non-existent fromShape '${binding.fromShapeId}'`); + } else if (fromShape.type !== "arrow") { + errors.push(`Binding '${bindingId}' fromShape '${binding.fromShapeId}' is not an arrow`); + } + + if (!document.shapes[binding.toShapeId]) { + errors.push(`Binding '${bindingId}' references non-existent toShape '${binding.toShapeId}'`); + } + + if (binding.handle !== "start" && binding.handle !== "end") { + errors.push(`Binding '${bindingId}' has invalid handle '${binding.handle}'`); + } + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + + return { ok: true }; +} diff --git a/packages/core/tests/model.test.ts b/packages/core/tests/model.test.ts new file mode 100644 index 0000000..0f8004a --- /dev/null +++ b/packages/core/tests/model.test.ts @@ -0,0 +1,1084 @@ +import { describe, expect, it } from "vitest"; +import { + type ArrowProps, + BindingRecord, + createId, + Document, + type EllipseProps, + type LineProps, + PageRecord, + type RectProps, + ShapeRecord, + type TextProps, + validateDoc, +} from "../src/model"; + +describe("createId", () => { + it("should generate a valid UUID without prefix", () => { + const id = createId(); + + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + it("should generate a UUID with prefix", () => { + const id = createId("shape"); + expect(id).toMatch(/^shape:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + it.each([{ prefix: "page" }, { prefix: "shape" }, { prefix: "binding" }, { prefix: "custom" }])( + "should handle prefix: $prefix", + ({ prefix }) => { + const id = createId(prefix); + expect(id).toContain(`${prefix}:`); + }, + ); + + it("should generate unique IDs", () => { + const ids = new Set(); + for (let i = 0; i < 1000; i++) { + ids.add(createId()); + } + expect(ids.size).toBe(1000); + }); + + it("should generate unique IDs with prefix", () => { + const ids = new Set(); + for (let i = 0; i < 1000; i++) { + ids.add(createId("test")); + } + expect(ids.size).toBe(1000); + }); +}); + +describe("PageRecord", () => { + describe("create", () => { + it("should create a page with generated ID", () => { + const page = PageRecord.create("My Page"); + expect(page.id).toMatch(/^page:/); + expect(page.name).toBe("My Page"); + expect(page.shapeIds).toEqual([]); + }); + + it("should create a page with custom ID", () => { + const page = PageRecord.create("Test Page", "page:123"); + expect(page.id).toBe("page:123"); + expect(page.name).toBe("Test Page"); + }); + + it.each([{ name: "Untitled" }, { name: "Page 1" }, { name: "" }, { + name: "A very long page name with special chars !@#$%", + }])("should create page with name: \"$name\"", ({ name }) => { + const page = PageRecord.create(name); + expect(page.name).toBe(name); + expect(page.shapeIds).toEqual([]); + }); + }); + + describe("clone", () => { + it("should create a copy of the page", () => { + const page = PageRecord.create("Test"); + page.shapeIds = ["shape1", "shape2"]; + + const cloned = PageRecord.clone(page); + + expect(cloned).toEqual(page); + expect(cloned).not.toBe(page); + expect(cloned.shapeIds).not.toBe(page.shapeIds); + }); + + it("should deep clone shapeIds array", () => { + const page = PageRecord.create("Test"); + page.shapeIds = ["shape1", "shape2"]; + + const cloned = PageRecord.clone(page); + cloned.shapeIds.push("shape3"); + + expect(page.shapeIds).toEqual(["shape1", "shape2"]); + expect(cloned.shapeIds).toEqual(["shape1", "shape2", "shape3"]); + }); + }); +}); + +describe("ShapeRecord", () => { + const pageId = "page:test"; + + describe("createRect", () => { + it("should create a rectangle shape with generated ID", () => { + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }; + const shape = ShapeRecord.createRect(pageId, 10, 20, props); + + expect(shape.id).toMatch(/^shape:/); + expect(shape.type).toBe("rect"); + expect(shape.pageId).toBe(pageId); + expect(shape.x).toBe(10); + expect(shape.y).toBe(20); + expect(shape.rot).toBe(0); + expect(shape.props).toEqual(props); + }); + + it("should create a rectangle with custom ID", () => { + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }; + const shape = ShapeRecord.createRect(pageId, 10, 20, props, "shape:custom"); + + expect(shape.id).toBe("shape:custom"); + }); + + it.each([{ w: 0, h: 0, fill: "transparent", stroke: "none", radius: 0 }, { + w: 1000, + h: 500, + fill: "#ff0000", + stroke: "#00ff00", + radius: 10, + }, { w: 50.5, h: 25.3, fill: "rgba(0,0,0,0.5)", stroke: "#123456", radius: 2.5 }])( + "should create rect with props: %o", + (props) => { + const shape = ShapeRecord.createRect(pageId, 0, 0, props as RectProps); + expect(shape.props).toEqual(props); + }, + ); + }); + + describe("createEllipse", () => { + it("should create an ellipse shape", () => { + const props: EllipseProps = { w: 100, h: 50, fill: "#fff", stroke: "#000" }; + const shape = ShapeRecord.createEllipse(pageId, 10, 20, props); + + expect(shape.id).toMatch(/^shape:/); + expect(shape.type).toBe("ellipse"); + expect(shape.pageId).toBe(pageId); + expect(shape.x).toBe(10); + expect(shape.y).toBe(20); + expect(shape.rot).toBe(0); + expect(shape.props).toEqual(props); + }); + }); + + describe("createLine", () => { + it("should create a line shape", () => { + const props: LineProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; + const shape = ShapeRecord.createLine(pageId, 10, 20, props); + + expect(shape.id).toMatch(/^shape:/); + expect(shape.type).toBe("line"); + expect(shape.props).toEqual(props); + }); + + it("should handle negative coordinates in line endpoints", () => { + const props: LineProps = { a: { x: -50, y: -30 }, b: { x: 100, y: 200 }, stroke: "#000", width: 1 }; + const shape = ShapeRecord.createLine(pageId, 0, 0, props); + + expect(shape.props.a).toEqual({ x: -50, y: -30 }); + expect(shape.props.b).toEqual({ x: 100, y: 200 }); + }); + }); + + describe("createArrow", () => { + it("should create an arrow shape", () => { + const props: ArrowProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; + const shape = ShapeRecord.createArrow(pageId, 10, 20, props); + + expect(shape.id).toMatch(/^shape:/); + expect(shape.type).toBe("arrow"); + expect(shape.props).toEqual(props); + }); + }); + + describe("createText", () => { + it("should create a text shape without width", () => { + const props: TextProps = { text: "Hello", fontSize: 16, fontFamily: "Arial", color: "#000" }; + const shape = ShapeRecord.createText(pageId, 10, 20, props); + + expect(shape.id).toMatch(/^shape:/); + expect(shape.type).toBe("text"); + expect(shape.props.text).toBe("Hello"); + expect(shape.props.w).toBeUndefined(); + }); + + it("should create a text shape with width", () => { + const props: TextProps = { text: "Hello", fontSize: 16, fontFamily: "Arial", color: "#000", w: 200 }; + const shape = ShapeRecord.createText(pageId, 10, 20, props); + + expect(shape.props.w).toBe(200); + }); + + it.each([{ text: "", fontSize: 12, fontFamily: "Arial", color: "#000" }, { + text: "Multi\nline\ntext", + fontSize: 24, + fontFamily: "Helvetica", + color: "#ff0000", + }, { text: "Special chars: !@#$%^&*()", fontSize: 14, fontFamily: "Courier", color: "rgb(0,0,0)" }])( + "should create text with props: %o", + (props) => { + const shape = ShapeRecord.createText(pageId, 0, 0, props as TextProps); + expect(shape.props.text).toBe(props.text); + expect(shape.props.fontSize).toBe(props.fontSize); + }, + ); + }); + + describe("clone", () => { + it("should clone a rect shape", () => { + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }; + const shape = ShapeRecord.createRect(pageId, 10, 20, props); + + const cloned = ShapeRecord.clone(shape); + + expect(cloned).toEqual(shape); + expect(cloned).not.toBe(shape); + expect(cloned.props).not.toBe(shape.props); + }); + + it("should deep clone props", () => { + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }; + const shape = ShapeRecord.createRect(pageId, 10, 20, props); + + const cloned = ShapeRecord.clone(shape); + if (cloned.type === "rect") { + cloned.props.w = 200; + } + + expect(shape.props.w).toBe(100); + }); + + it("should clone line shape with Vec2 props", () => { + const props: LineProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; + const shape = ShapeRecord.createLine(pageId, 0, 0, props); + + const cloned = ShapeRecord.clone(shape); + + expect(cloned).toEqual(shape); + expect(cloned.props).not.toBe(shape.props); + }); + }); + + describe("position and rotation", () => { + it("should create shapes at different positions", () => { + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }; + + const shape1 = ShapeRecord.createRect(pageId, 0, 0, props); + const shape2 = ShapeRecord.createRect(pageId, 100, 200, props); + const shape3 = ShapeRecord.createRect(pageId, -50, -30, props); + + expect(shape1.x).toBe(0); + expect(shape1.y).toBe(0); + expect(shape2.x).toBe(100); + expect(shape2.y).toBe(200); + expect(shape3.x).toBe(-50); + expect(shape3.y).toBe(-30); + }); + + it("should initialize rotation to 0", () => { + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }; + const shape = ShapeRecord.createRect(pageId, 0, 0, props); + + expect(shape.rot).toBe(0); + }); + }); +}); + +describe("BindingRecord", () => { + describe("create", () => { + it("should create a binding with default anchor", () => { + const binding = BindingRecord.create("arrow1", "shape1", "start"); + + expect(binding.id).toMatch(/^binding:/); + expect(binding.type).toBe("arrow-end"); + expect(binding.fromShapeId).toBe("arrow1"); + expect(binding.toShapeId).toBe("shape1"); + expect(binding.handle).toBe("start"); + expect(binding.anchor).toEqual({ kind: "center" }); + }); + + it("should create a binding with custom ID", () => { + const binding = BindingRecord.create("arrow1", "shape1", "end", { kind: "center" }, "binding:custom"); + + expect(binding.id).toBe("binding:custom"); + }); + + it.each([{ handle: "start" as const }, { handle: "end" as const }])( + "should create binding with handle: $handle", + ({ handle }) => { + const binding = BindingRecord.create("arrow1", "shape1", handle); + expect(binding.handle).toBe(handle); + }, + ); + + it("should create binding with custom anchor", () => { + const anchor = { kind: "center" as const }; + const binding = BindingRecord.create("arrow1", "shape1", "start", anchor); + + expect(binding.anchor).toEqual(anchor); + }); + }); + + describe("clone", () => { + it("should create a copy of the binding", () => { + const binding = BindingRecord.create("arrow1", "shape1", "start"); + + const cloned = BindingRecord.clone(binding); + + expect(cloned).toEqual(binding); + expect(cloned).not.toBe(binding); + expect(cloned.anchor).not.toBe(binding.anchor); + }); + + it("should deep clone anchor", () => { + const binding = BindingRecord.create("arrow1", "shape1", "start"); + + const cloned = BindingRecord.clone(binding); + + expect(cloned.anchor).toEqual(binding.anchor); + expect(cloned.anchor).not.toBe(binding.anchor); + }); + }); +}); + +describe("Document", () => { + describe("create", () => { + it("should create an empty document", () => { + const doc = Document.create(); + + expect(doc.pages).toEqual({}); + expect(doc.shapes).toEqual({}); + expect(doc.bindings).toEqual({}); + }); + }); + + describe("clone", () => { + it("should clone an empty document", () => { + const doc = Document.create(); + const cloned = Document.clone(doc); + + expect(cloned).toEqual(doc); + expect(cloned).not.toBe(doc); + }); + + it("should deep clone document with pages and shapes", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const cloned = Document.clone(doc); + + expect(cloned).toEqual(doc); + expect(cloned.pages).not.toBe(doc.pages); + expect(cloned.shapes).not.toBe(doc.shapes); + expect(cloned.pages.page1).not.toBe(doc.pages.page1); + expect(cloned.shapes.shape1).not.toBe(doc.shapes.shape1); + }); + + it("should deep clone bindings", () => { + const doc = Document.create(); + const binding = BindingRecord.create("arrow1", "shape1", "start", { kind: "center" }, "binding1"); + doc.bindings = { binding1: binding }; + + const cloned = Document.clone(doc); + + expect(cloned.bindings).not.toBe(doc.bindings); + expect(cloned.bindings.binding1).not.toBe(doc.bindings.binding1); + expect(cloned.bindings.binding1).toEqual(doc.bindings.binding1); + }); + }); +}); + +describe("validateDoc", () => { + describe("valid documents", () => { + it("should validate empty document", () => { + const doc = Document.create(); + const result = validateDoc(doc); + + expect(result.ok).toBe(true); + }); + + it("should validate document with page and shape", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(true); + }); + + it("should validate document with multiple shapes", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape1 = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + const shape2 = ShapeRecord.createEllipse( + "page1", + 50, + 50, + { w: 75, h: 75, fill: "#000", stroke: "#fff" }, + "shape2", + ); + + page.shapeIds = ["shape1", "shape2"]; + doc.pages = { page1: page }; + doc.shapes = { shape1, shape2 }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(true); + }); + + it("should validate document with binding", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const arrow = ShapeRecord.createArrow("page1", 0, 0, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 0 }, + stroke: "#000", + width: 2, + }, "arrow1"); + const rect = ShapeRecord.createRect( + "page1", + 100, + 0, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect1", + ); + const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "center" }, "binding1"); + + page.shapeIds = ["arrow1", "rect1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: arrow, rect1: rect }; + doc.bindings = { binding1: binding }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(true); + }); + }); + + describe("invalid documents", () => { + it("should reject document with shapes but no pages", () => { + const doc = Document.create(); + const shape = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Document has shapes but no pages"); + } + }); + + it("should reject shape with mismatched ID", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { wrongId: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Shape key 'wrongId' does not match shape.id 'shape1'"); + } + }); + + it("should reject shape referencing non-existent page", () => { + const doc = Document.create(); + const shape = ShapeRecord.createRect("nonexistent", 0, 0, { + w: 100, + h: 50, + fill: "#fff", + stroke: "#000", + radius: 0, + }, "shape1"); + + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Shape 'shape1' references non-existent page 'nonexistent'"); + } + }); + + it("should reject shape not listed in page shapeIds", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Shape 'shape1' not listed in page 'page1' shapeIds"); + } + }); + + it("should reject page referencing non-existent shape", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + + page.shapeIds = ["nonexistent"]; + doc.pages = { page1: page }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Page 'page1' references non-existent shape 'nonexistent'"); + } + }); + + it("should reject page with duplicate shape IDs", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + + page.shapeIds = ["shape1", "shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Page 'page1' has duplicate shape IDs"); + } + }); + + it("should reject binding to non-existent fromShape", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const rect = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect1", + ); + const binding = BindingRecord.create("nonexistent", "rect1", "end", { kind: "center" }, "binding1"); + + page.shapeIds = ["rect1"]; + doc.pages = { page1: page }; + doc.shapes = { rect1: rect }; + doc.bindings = { binding1: binding }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Binding 'binding1' references non-existent fromShape 'nonexistent'"); + } + }); + + it("should reject binding to non-existent toShape", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const arrow = ShapeRecord.createArrow("page1", 0, 0, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 0 }, + stroke: "#000", + width: 2, + }, "arrow1"); + const binding = BindingRecord.create("arrow1", "nonexistent", "end", { kind: "center" }, "binding1"); + + page.shapeIds = ["arrow1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: arrow }; + doc.bindings = { binding1: binding }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Binding 'binding1' references non-existent toShape 'nonexistent'"); + } + }); + + it("should reject binding from non-arrow shape", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const rect1 = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect1", + ); + const rect2 = ShapeRecord.createRect( + "page1", + 100, + 0, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect2", + ); + const binding = BindingRecord.create("rect1", "rect2", "start", { kind: "center" }, "binding1"); + + page.shapeIds = ["rect1", "rect2"]; + doc.pages = { page1: page }; + doc.shapes = { rect1, rect2 }; + doc.bindings = { binding1: binding }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Binding 'binding1' fromShape 'rect1' is not an arrow"); + } + }); + + it("should reject rect with negative width", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: -100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Rect shape 'shape1' has negative width"); + } + }); + + it("should reject rect with negative height", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: -50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Rect shape 'shape1' has negative height"); + } + }); + + it("should reject rect with negative radius", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: -5 }, + "shape1", + ); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Rect shape 'shape1' has negative radius"); + } + }); + + it("should reject ellipse with negative dimensions", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createEllipse( + "page1", + 0, + 0, + { w: -100, h: 50, fill: "#fff", stroke: "#000" }, + "shape1", + ); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Ellipse shape 'shape1' has negative width"); + } + }); + + it("should reject line with negative width", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createLine("page1", 0, 0, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 0 }, + stroke: "#000", + width: -2, + }, "shape1"); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("line shape 'shape1' has negative width"); + } + }); + + it("should reject text with invalid fontSize", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createText("page1", 0, 0, { + text: "Test", + fontSize: 0, + fontFamily: "Arial", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Text shape 'shape1' has invalid fontSize"); + } + }); + + it("should reject text with negative width", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createText("page1", 0, 0, { + text: "Test", + fontSize: 12, + fontFamily: "Arial", + color: "#000", + w: -100, + }, "shape1"); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toContain("Text shape 'shape1' has negative width"); + } + }); + + it("should collect multiple errors", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape1 = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: -100, h: -50, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + const shape2 = ShapeRecord.createRect("nonexistent", 0, 0, { + w: 100, + h: 50, + fill: "#fff", + stroke: "#000", + radius: 0, + }, "shape2"); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1, shape2 }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.length).toBeGreaterThan(1); + } + }); + }); + + describe("edge cases", () => { + it("should accept zero-sized shapes", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 0, h: 0, fill: "#fff", stroke: "#000", radius: 0 }, + "shape1", + ); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(true); + }); + + it("should accept text with undefined width", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createText("page1", 0, 0, { + text: "Test", + fontSize: 12, + fontFamily: "Arial", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(true); + }); + + it("should accept empty page name", () => { + const doc = Document.create(); + const page = PageRecord.create("", "page1"); + doc.pages = { page1: page }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(true); + }); + }); +}); + +describe("JSON serialization", () => { + it("should round-trip empty document", () => { + const doc = Document.create(); + const json = JSON.stringify(doc); + const parsed = JSON.parse(json); + + expect(parsed).toEqual(doc); + expect(validateDoc(parsed).ok).toBe(true); + }); + + it("should round-trip document with page and shape", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const shape = ShapeRecord.createRect( + "page1", + 10, + 20, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }, + "shape1", + ); + + page.shapeIds = ["shape1"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: shape }; + + const json = JSON.stringify(doc); + const parsed = JSON.parse(json); + + expect(parsed).toEqual(doc); + expect(validateDoc(parsed).ok).toBe(true); + }); + + it("should round-trip document with all shape types", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + + const rect = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }, + "shape1", + ); + const ellipse = ShapeRecord.createEllipse( + "page1", + 100, + 100, + { w: 75, h: 75, fill: "#f00", stroke: "#000" }, + "shape2", + ); + const line = ShapeRecord.createLine("page1", 200, 200, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 50 }, + stroke: "#000", + width: 2, + }, "shape3"); + const arrow = ShapeRecord.createArrow("page1", 300, 300, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 0 }, + stroke: "#000", + width: 2, + }, "shape4"); + const text = ShapeRecord.createText("page1", 400, 400, { + text: "Hello World", + fontSize: 16, + fontFamily: "Arial", + color: "#000", + w: 200, + }, "shape5"); + + page.shapeIds = ["shape1", "shape2", "shape3", "shape4", "shape5"]; + doc.pages = { page1: page }; + doc.shapes = { shape1: rect, shape2: ellipse, shape3: line, shape4: arrow, shape5: text }; + + const json = JSON.stringify(doc); + const parsed = JSON.parse(json); + + expect(parsed).toEqual(doc); + expect(validateDoc(parsed).ok).toBe(true); + }); + + it("should round-trip document with bindings", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const arrow = ShapeRecord.createArrow("page1", 0, 0, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 0 }, + stroke: "#000", + width: 2, + }, "arrow1"); + const rect = ShapeRecord.createRect( + "page1", + 100, + 0, + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, + "rect1", + ); + const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "center" }, "binding1"); + + page.shapeIds = ["arrow1", "rect1"]; + doc.pages = { page1: page }; + doc.shapes = { arrow1: arrow, rect1: rect }; + doc.bindings = { binding1: binding }; + + const json = JSON.stringify(doc); + const parsed = JSON.parse(json); + + expect(parsed).toEqual(doc); + expect(validateDoc(parsed).ok).toBe(true); + }); + + it("should round-trip complex document", () => { + const doc = Document.create(); + const page1 = PageRecord.create("Page 1", "page1"); + const page2 = PageRecord.create("Page 2", "page2"); + + const shape1 = ShapeRecord.createRect( + "page1", + 0, + 0, + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }, + "shape1", + ); + const shape2 = ShapeRecord.createEllipse( + "page1", + 100, + 100, + { w: 75, h: 75, fill: "#f00", stroke: "#000" }, + "shape2", + ); + const shape3 = ShapeRecord.createArrow("page2", 0, 0, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 0 }, + stroke: "#000", + width: 2, + }, "shape3"); + const shape4 = ShapeRecord.createRect( + "page2", + 100, + 0, + { w: 50, h: 50, fill: "#0f0", stroke: "#000", radius: 0 }, + "shape4", + ); + + const binding = BindingRecord.create("shape3", "shape4", "end", { kind: "center" }, "binding1"); + + page1.shapeIds = ["shape1", "shape2"]; + page2.shapeIds = ["shape3", "shape4"]; + + doc.pages = { page1, page2 }; + doc.shapes = { shape1, shape2, shape3, shape4 }; + doc.bindings = { binding1: binding }; + + const json = JSON.stringify(doc); + const parsed = JSON.parse(json); + + expect(parsed).toEqual(doc); + expect(validateDoc(parsed).ok).toBe(true); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3dcf14..5e9c9ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: packages/core: dependencies: + rxjs: + specifier: ^7.8.2 + version: 7.8.2 uuid: specifier: ^13.0.0 version: 13.0.0 @@ -1229,6 +1232,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} @@ -2536,6 +2542,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.54.0 fsevents: 2.3.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + semver@7.7.3: {} shebang-command@2.0.0: @@ -2604,8 +2614,7 @@ snapshots: - synckit - vue-tsc - tslib@2.8.1: - optional: true + tslib@2.8.1: {} type-check@0.4.0: dependencies: -- 2.51.2