web based infinite canvas

feat: data model

+1405 -30
+41 -27
TODO.txt
··· 81 81 Goal: define the minimal data model that can represent a drawing. 82 82 83 83 Records & ID (/packages/core/src/model): 84 - [ ] Implement createId(prefix) -> uuid (v4) 85 - [ ] Define PageRecord { id, name, shapeIds: string[] } 86 - [ ] Define ShapeRecord base: 84 + [x] Implement createId(prefix) -> uuid (v4) 85 + [x] Define PageRecord { id, name, shapeIds: string[] } 86 + [x] Define ShapeRecord base: 87 87 - id, type, pageId 88 88 - x, y, rot 89 89 - props: object (type-specific) 90 90 91 - [ ] Define shape types (minimal): 91 + [x] Define shape types (minimal): 92 92 - rect: { w, h, fill, stroke, radius } 93 93 - ellipse: { w, h, fill, stroke } 94 94 - line: { a: Vec2, b: Vec2, stroke, width } 95 95 - arrow: { a: Vec2, b: Vec2, stroke, width } 96 96 - text: { text, fontSize, fontFamily, color, w? } 97 97 98 - [ ] Define BindingRecord (for arrow endpoints): 98 + [x] Define BindingRecord (for arrow endpoints): 99 99 - id, type: "arrow-end" 100 100 - fromShapeId (arrow id) 101 101 - toShapeId (target shape id) ··· 103 103 - anchor: e.g. { kind: "center" } for v0 104 104 105 105 Validation: 106 - [ ] validateDoc(doc) -> { ok | errors[] } 107 - [ ] Add test: invalid binding to missing shape returns error 106 + [x] validateDoc(doc) -> { ok | errors[] } 108 107 109 108 (DoD): 110 109 - You can serialize a doc with a page + 1 shape to JSON and validate it. ··· 113 112 4. Milestone D: Store + selectors (reactive core) *wb-D* 114 113 ============================================================================== 115 114 116 - Goal: a fast, deterministic state container for the editor. 115 + Goal: a fast, deterministic state container for the editor using RxJS 117 116 118 - Store (/packages/core/src/store): 117 + Store (/packages/core/src/store) - RxJS + SvelteKit (runes) friendly 118 + 119 + Core types: 119 120 [ ] Define EditorState: 120 - - doc (pages, shapes, bindings) 121 - - ui: { currentPageId, selectionIds[], toolId } 122 - - camera 121 + - doc: { pages, shapes, bindings } 122 + - ui: { currentPageId, selectionIds: string[], toolId: ToolId } 123 + - camera: { x, y, zoom } 123 124 124 - [ ] Implement Store with: 125 - - getState() 126 - - setState(updater) 127 - - subscribe(listener) -> unsubscribe 125 + RxJS store (BehaviorSubject-backed): 126 + [ ] Implement createEditorStore(initial: EditorState) that exposes: 127 + - state$: Observable<EditorState> (read stream) 128 + - getState(): EditorState (sync snapshot) 129 + - setState(updater: (s) => s): void (mutation API) 130 + - subscribe(listener): () => void (Svelte-compatible subscribe) 131 + - select(selector, eq?): Observable<T> (derived streams) 128 132 129 - [ ] Implement selectors (pure functions): 133 + Notes: 134 + - Use BehaviorSubject so new subscribers immediately get the current value. 135 + - subscribe must return an unsubscribe function. 136 + 137 + Selectors (pure functions, no RxJS): 138 + [ ] Implement selectors in /packages/core/src/store/selectors.ts: 130 139 - getCurrentPage(state) 131 140 - getShapesOnCurrentPage(state) 132 141 - getSelectedShapes(state) 133 142 134 - [ ] Implement invariants: 135 - - selectionIds only reference existing shapes 136 - - currentPageId must exist 143 + Invariants (pick “repair” and test it): 144 + [ ] Implement enforceInvariants(state): EditorState (repair strategy): 145 + - selectionIds := selectionIds filtered to existing shapes 146 + - currentPageId must exist: 147 + - if missing, set to first existing page 148 + - if no pages exist, create a default page and set it 149 + [ ] Ensure setState always runs enforceInvariants before publishing next state 137 150 138 - Tests: 139 - [ ] subscribe fires exactly once per setState 140 - [ ] invariants enforced (reject or repair, pick one and test it) 151 + Tests (vitest): 152 + [ ] subscribe immediately receives current state upon subscription (BehaviorSubject behavior) 153 + [ ] subscribe fires exactly once per setState call 154 + [ ] invariants are enforced on any update (selection filtered, page fixed/created) 141 155 142 156 (DoD): 143 - - Renderer can subscribe and redraw on any state change. 144 - 157 + - Renderer can subscribe to state$ (or subscribe()) and redraw on any change. 158 + - SvelteKit can bridge to runes with $effect unsubscribe cleanup. 145 159 146 160 ============================================================================== 147 161 5. Milestone E: Canvas renderer (read-only) *wb-E* ··· 336 350 337 351 Tests: 338 352 [ ] moving bound shape changes resolved endpoint 339 - [ ] binding to missing target is ignored (or removed)—pick one and test 353 + [ ] binding to missing target is ignored (or removed)-pick one and test 340 354 341 355 (DoD): 342 356 - Arrows remain connected to moved shapes (center-to-center is fine for v0). ··· 429 443 [ ] Implement exportSelectionToPNG (render selection bounds) 430 444 [ ] Implement SVG export for basic shapes: 431 445 - rect/ellipse/line/arrow/text 432 - - camera transform baked into output or removed—pick one and document 446 + - camera transform baked into output or removed-pick one and document 433 447 434 448 Tests: 435 449 [ ] exported SVG parses and contains expected elements
+4 -1
eslint.config.js
··· 10 10 tseslint.configs.recommended, 11 11 eslintPluginUnicorn.configs.recommended, 12 12 [{ 13 - rules: { "unicorn/no-null": "off", "unicorn/prevent-abbreviations": ["error", { "replacements": { "i": false } }] }, 13 + rules: { 14 + "unicorn/no-null": "off", 15 + "unicorn/prevent-abbreviations": ["error", { "replacements": { "i": false, "props": false, "doc": false } }], 16 + }, 14 17 }], 15 18 );
+1
packages/core/package.json
··· 38 38 "vitest": "^4.0.16" 39 39 }, 40 40 "dependencies": { 41 + "rxjs": "^7.8.2", 41 42 "uuid": "^13.0.0" 42 43 } 43 44 }
+264
packages/core/src/model.ts
··· 1 + import { v4 } from "uuid"; 2 + import type { Vec2 } from "./math"; 3 + /** 4 + * Generate a unique ID with an optional prefix 5 + * @param prefix - Optional prefix for the ID (e.g., 'shape', 'page', 'binding') 6 + * @returns A unique ID string (UUID v4 format with prefix) 7 + */ 8 + export function createId(prefix?: string): string { 9 + const id = v4(); 10 + return prefix ? `${prefix}:${id}` : id; 11 + } 12 + 13 + export type PageRecord = { id: string; name: string; shapeIds: string[] }; 14 + 15 + export const PageRecord = { 16 + /** 17 + * Create a new page record 18 + */ 19 + create(name: string, id?: string): PageRecord { 20 + return { id: id ?? createId("page"), name, shapeIds: [] }; 21 + }, 22 + 23 + /** 24 + * Clone a page record 25 + */ 26 + clone(page: PageRecord): PageRecord { 27 + return { id: page.id, name: page.name, shapeIds: [...page.shapeIds] }; 28 + }, 29 + }; 30 + 31 + export type RectProps = { w: number; h: number; fill: string; stroke: string; radius: number }; 32 + export type EllipseProps = { w: number; h: number; fill: string; stroke: string }; 33 + export type LineProps = { a: Vec2; b: Vec2; stroke: string; width: number }; 34 + export type ArrowProps = { a: Vec2; b: Vec2; stroke: string; width: number }; 35 + export type TextProps = { text: string; fontSize: number; fontFamily: string; color: string; w?: number }; 36 + 37 + export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text"; 38 + 39 + export type BaseShape = { id: string; type: ShapeType; pageId: string; x: number; y: number; rot: number }; 40 + export type RectShape = BaseShape & { type: "rect"; props: RectProps }; 41 + export type EllipseShape = BaseShape & { type: "ellipse"; props: EllipseProps }; 42 + export type LineShape = BaseShape & { type: "line"; props: LineProps }; 43 + export type ArrowShape = BaseShape & { type: "arrow"; props: ArrowProps }; 44 + export type TextShape = BaseShape & { type: "text"; props: TextProps }; 45 + 46 + export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape; 47 + 48 + export const ShapeRecord = { 49 + /** 50 + * Create a rectangle shape 51 + */ 52 + createRect(pageId: string, x: number, y: number, properties: RectProps, id?: string): RectShape { 53 + return { id: id ?? createId("shape"), type: "rect", pageId, x, y, rot: 0, props: properties }; 54 + }, 55 + 56 + /** 57 + * Create an ellipse shape 58 + */ 59 + createEllipse(pageId: string, x: number, y: number, properties: EllipseProps, id?: string): EllipseShape { 60 + return { id: id ?? createId("shape"), type: "ellipse", pageId, x, y, rot: 0, props: properties }; 61 + }, 62 + 63 + /** 64 + * Create a line shape 65 + */ 66 + createLine(pageId: string, x: number, y: number, properties: LineProps, id?: string): LineShape { 67 + return { id: id ?? createId("shape"), type: "line", pageId, x, y, rot: 0, props: properties }; 68 + }, 69 + 70 + /** 71 + * Create an arrow shape 72 + */ 73 + createArrow(pageId: string, x: number, y: number, properties: ArrowProps, id?: string): ArrowShape { 74 + return { id: id ?? createId("shape"), type: "arrow", pageId, x, y, rot: 0, props: properties }; 75 + }, 76 + 77 + /** 78 + * Create a text shape 79 + */ 80 + createText(pageId: string, x: number, y: number, properties: TextProps, id?: string): TextShape { 81 + return { id: id ?? createId("shape"), type: "text", pageId, x, y, rot: 0, props: properties }; 82 + }, 83 + 84 + /** 85 + * Clone a shape record 86 + */ 87 + clone(shape: ShapeRecord): ShapeRecord { 88 + return { ...shape, props: { ...shape.props } } as ShapeRecord; 89 + }, 90 + }; 91 + 92 + export type BindingType = "arrow-end"; 93 + export type BindingHandle = "start" | "end"; 94 + 95 + export type BindingAnchor = { 96 + // TODO: 'edge', 'corner', etc. 97 + kind: "center"; 98 + }; 99 + 100 + export type BindingRecord = { 101 + id: string; 102 + type: BindingType; 103 + fromShapeId: string; 104 + toShapeId: string; 105 + handle: BindingHandle; 106 + anchor: BindingAnchor; 107 + }; 108 + 109 + export const BindingRecord = { 110 + /** 111 + * Create a binding record for arrow endpoints 112 + */ 113 + create( 114 + fromShapeId: string, 115 + toShapeId: string, 116 + handle: BindingHandle, 117 + anchor?: BindingAnchor, 118 + id?: string, 119 + ): BindingRecord { 120 + if (!anchor) { 121 + anchor = { kind: "center" }; 122 + } 123 + return { id: id ?? createId("binding"), type: "arrow-end", fromShapeId, toShapeId, handle, anchor }; 124 + }, 125 + 126 + /** 127 + * Clone a binding record 128 + */ 129 + clone(binding: BindingRecord): BindingRecord { 130 + return { ...binding, anchor: { ...binding.anchor } }; 131 + }, 132 + }; 133 + 134 + export type Document = { 135 + pages: Record<string, PageRecord>; 136 + shapes: Record<string, ShapeRecord>; 137 + bindings: Record<string, BindingRecord>; 138 + }; 139 + 140 + export const Document = { 141 + /** 142 + * Create an empty document 143 + */ 144 + create(): Document { 145 + return { pages: {}, shapes: {}, bindings: {} }; 146 + }, 147 + 148 + /** 149 + * Clone a document 150 + */ 151 + clone(document: Document): Document { 152 + return { 153 + pages: Object.fromEntries(Object.entries(document.pages).map(([id, page]) => [id, PageRecord.clone(page)])), 154 + shapes: Object.fromEntries(Object.entries(document.shapes).map(([id, shape]) => [id, ShapeRecord.clone(shape)])), 155 + bindings: Object.fromEntries( 156 + Object.entries(document.bindings).map(([id, binding]) => [id, BindingRecord.clone(binding)]), 157 + ), 158 + }; 159 + }, 160 + }; 161 + 162 + export type ValidationResult = { ok: true } | { ok: false; errors: string[] }; 163 + 164 + /** 165 + * Validate a document for consistency and referential integrity 166 + * @param doc - The document to validate 167 + * @returns ValidationResult with ok status and any errors found 168 + */ 169 + export function validateDoc(document: Document): ValidationResult { 170 + const errors: string[] = []; 171 + 172 + if (Object.keys(document.pages).length === 0 && Object.keys(document.shapes).length > 0) { 173 + errors.push("Document has shapes but no pages"); 174 + } 175 + 176 + for (const [shapeId, shape] of Object.entries(document.shapes)) { 177 + if (shape.id !== shapeId) { 178 + errors.push(`Shape key '${shapeId}' does not match shape.id '${shape.id}'`); 179 + } 180 + 181 + if (!document.pages[shape.pageId]) { 182 + errors.push(`Shape '${shapeId}' references non-existent page '${shape.pageId}'`); 183 + } 184 + 185 + const page = document.pages[shape.pageId]; 186 + if (page && !page.shapeIds.includes(shapeId)) { 187 + errors.push(`Shape '${shapeId}' not listed in page '${shape.pageId}' shapeIds`); 188 + } 189 + 190 + switch (shape.type) { 191 + case "rect": { 192 + if (shape.props.w < 0) errors.push(`Rect shape '${shapeId}' has negative width`); 193 + if (shape.props.h < 0) errors.push(`Rect shape '${shapeId}' has negative height`); 194 + if (shape.props.radius < 0) errors.push(`Rect shape '${shapeId}' has negative radius`); 195 + 196 + break; 197 + } 198 + case "ellipse": { 199 + if (shape.props.w < 0) errors.push(`Ellipse shape '${shapeId}' has negative width`); 200 + if (shape.props.h < 0) errors.push(`Ellipse shape '${shapeId}' has negative height`); 201 + 202 + break; 203 + } 204 + case "line": 205 + case "arrow": { 206 + if (shape.props.width < 0) errors.push(`${shape.type} shape '${shapeId}' has negative width`); 207 + 208 + break; 209 + } 210 + case "text": { 211 + if (shape.props.fontSize <= 0) errors.push(`Text shape '${shapeId}' has invalid fontSize`); 212 + if (shape.props.w !== undefined && shape.props.w < 0) { 213 + errors.push(`Text shape '${shapeId}' has negative width`); 214 + } 215 + 216 + break; 217 + } 218 + } 219 + } 220 + 221 + for (const [pageId, page] of Object.entries(document.pages)) { 222 + if (page.id !== pageId) { 223 + errors.push(`Page key '${pageId}' does not match page.id '${page.id}'`); 224 + } 225 + 226 + for (const shapeId of page.shapeIds) { 227 + if (!document.shapes[shapeId]) { 228 + errors.push(`Page '${pageId}' references non-existent shape '${shapeId}'`); 229 + } 230 + } 231 + 232 + const uniqueIds = new Set(page.shapeIds); 233 + if (uniqueIds.size !== page.shapeIds.length) { 234 + errors.push(`Page '${pageId}' has duplicate shape IDs`); 235 + } 236 + } 237 + 238 + for (const [bindingId, binding] of Object.entries(document.bindings)) { 239 + if (binding.id !== bindingId) { 240 + errors.push(`Binding key '${bindingId}' does not match binding.id '${binding.id}'`); 241 + } 242 + 243 + const fromShape = document.shapes[binding.fromShapeId]; 244 + if (!fromShape) { 245 + errors.push(`Binding '${bindingId}' references non-existent fromShape '${binding.fromShapeId}'`); 246 + } else if (fromShape.type !== "arrow") { 247 + errors.push(`Binding '${bindingId}' fromShape '${binding.fromShapeId}' is not an arrow`); 248 + } 249 + 250 + if (!document.shapes[binding.toShapeId]) { 251 + errors.push(`Binding '${bindingId}' references non-existent toShape '${binding.toShapeId}'`); 252 + } 253 + 254 + if (binding.handle !== "start" && binding.handle !== "end") { 255 + errors.push(`Binding '${bindingId}' has invalid handle '${binding.handle}'`); 256 + } 257 + } 258 + 259 + if (errors.length > 0) { 260 + return { ok: false, errors }; 261 + } 262 + 263 + return { ok: true }; 264 + }
+1084
packages/core/tests/model.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + type ArrowProps, 4 + BindingRecord, 5 + createId, 6 + Document, 7 + type EllipseProps, 8 + type LineProps, 9 + PageRecord, 10 + type RectProps, 11 + ShapeRecord, 12 + type TextProps, 13 + validateDoc, 14 + } from "../src/model"; 15 + 16 + describe("createId", () => { 17 + it("should generate a valid UUID without prefix", () => { 18 + const id = createId(); 19 + 20 + 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}$/); 21 + }); 22 + 23 + it("should generate a UUID with prefix", () => { 24 + const id = createId("shape"); 25 + 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}$/); 26 + }); 27 + 28 + it.each([{ prefix: "page" }, { prefix: "shape" }, { prefix: "binding" }, { prefix: "custom" }])( 29 + "should handle prefix: $prefix", 30 + ({ prefix }) => { 31 + const id = createId(prefix); 32 + expect(id).toContain(`${prefix}:`); 33 + }, 34 + ); 35 + 36 + it("should generate unique IDs", () => { 37 + const ids = new Set(); 38 + for (let i = 0; i < 1000; i++) { 39 + ids.add(createId()); 40 + } 41 + expect(ids.size).toBe(1000); 42 + }); 43 + 44 + it("should generate unique IDs with prefix", () => { 45 + const ids = new Set(); 46 + for (let i = 0; i < 1000; i++) { 47 + ids.add(createId("test")); 48 + } 49 + expect(ids.size).toBe(1000); 50 + }); 51 + }); 52 + 53 + describe("PageRecord", () => { 54 + describe("create", () => { 55 + it("should create a page with generated ID", () => { 56 + const page = PageRecord.create("My Page"); 57 + expect(page.id).toMatch(/^page:/); 58 + expect(page.name).toBe("My Page"); 59 + expect(page.shapeIds).toEqual([]); 60 + }); 61 + 62 + it("should create a page with custom ID", () => { 63 + const page = PageRecord.create("Test Page", "page:123"); 64 + expect(page.id).toBe("page:123"); 65 + expect(page.name).toBe("Test Page"); 66 + }); 67 + 68 + it.each([{ name: "Untitled" }, { name: "Page 1" }, { name: "" }, { 69 + name: "A very long page name with special chars !@#$%", 70 + }])("should create page with name: \"$name\"", ({ name }) => { 71 + const page = PageRecord.create(name); 72 + expect(page.name).toBe(name); 73 + expect(page.shapeIds).toEqual([]); 74 + }); 75 + }); 76 + 77 + describe("clone", () => { 78 + it("should create a copy of the page", () => { 79 + const page = PageRecord.create("Test"); 80 + page.shapeIds = ["shape1", "shape2"]; 81 + 82 + const cloned = PageRecord.clone(page); 83 + 84 + expect(cloned).toEqual(page); 85 + expect(cloned).not.toBe(page); 86 + expect(cloned.shapeIds).not.toBe(page.shapeIds); 87 + }); 88 + 89 + it("should deep clone shapeIds array", () => { 90 + const page = PageRecord.create("Test"); 91 + page.shapeIds = ["shape1", "shape2"]; 92 + 93 + const cloned = PageRecord.clone(page); 94 + cloned.shapeIds.push("shape3"); 95 + 96 + expect(page.shapeIds).toEqual(["shape1", "shape2"]); 97 + expect(cloned.shapeIds).toEqual(["shape1", "shape2", "shape3"]); 98 + }); 99 + }); 100 + }); 101 + 102 + describe("ShapeRecord", () => { 103 + const pageId = "page:test"; 104 + 105 + describe("createRect", () => { 106 + it("should create a rectangle shape with generated ID", () => { 107 + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }; 108 + const shape = ShapeRecord.createRect(pageId, 10, 20, props); 109 + 110 + expect(shape.id).toMatch(/^shape:/); 111 + expect(shape.type).toBe("rect"); 112 + expect(shape.pageId).toBe(pageId); 113 + expect(shape.x).toBe(10); 114 + expect(shape.y).toBe(20); 115 + expect(shape.rot).toBe(0); 116 + expect(shape.props).toEqual(props); 117 + }); 118 + 119 + it("should create a rectangle with custom ID", () => { 120 + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }; 121 + const shape = ShapeRecord.createRect(pageId, 10, 20, props, "shape:custom"); 122 + 123 + expect(shape.id).toBe("shape:custom"); 124 + }); 125 + 126 + it.each([{ w: 0, h: 0, fill: "transparent", stroke: "none", radius: 0 }, { 127 + w: 1000, 128 + h: 500, 129 + fill: "#ff0000", 130 + stroke: "#00ff00", 131 + radius: 10, 132 + }, { w: 50.5, h: 25.3, fill: "rgba(0,0,0,0.5)", stroke: "#123456", radius: 2.5 }])( 133 + "should create rect with props: %o", 134 + (props) => { 135 + const shape = ShapeRecord.createRect(pageId, 0, 0, props as RectProps); 136 + expect(shape.props).toEqual(props); 137 + }, 138 + ); 139 + }); 140 + 141 + describe("createEllipse", () => { 142 + it("should create an ellipse shape", () => { 143 + const props: EllipseProps = { w: 100, h: 50, fill: "#fff", stroke: "#000" }; 144 + const shape = ShapeRecord.createEllipse(pageId, 10, 20, props); 145 + 146 + expect(shape.id).toMatch(/^shape:/); 147 + expect(shape.type).toBe("ellipse"); 148 + expect(shape.pageId).toBe(pageId); 149 + expect(shape.x).toBe(10); 150 + expect(shape.y).toBe(20); 151 + expect(shape.rot).toBe(0); 152 + expect(shape.props).toEqual(props); 153 + }); 154 + }); 155 + 156 + describe("createLine", () => { 157 + it("should create a line shape", () => { 158 + const props: LineProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; 159 + const shape = ShapeRecord.createLine(pageId, 10, 20, props); 160 + 161 + expect(shape.id).toMatch(/^shape:/); 162 + expect(shape.type).toBe("line"); 163 + expect(shape.props).toEqual(props); 164 + }); 165 + 166 + it("should handle negative coordinates in line endpoints", () => { 167 + const props: LineProps = { a: { x: -50, y: -30 }, b: { x: 100, y: 200 }, stroke: "#000", width: 1 }; 168 + const shape = ShapeRecord.createLine(pageId, 0, 0, props); 169 + 170 + expect(shape.props.a).toEqual({ x: -50, y: -30 }); 171 + expect(shape.props.b).toEqual({ x: 100, y: 200 }); 172 + }); 173 + }); 174 + 175 + describe("createArrow", () => { 176 + it("should create an arrow shape", () => { 177 + const props: ArrowProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; 178 + const shape = ShapeRecord.createArrow(pageId, 10, 20, props); 179 + 180 + expect(shape.id).toMatch(/^shape:/); 181 + expect(shape.type).toBe("arrow"); 182 + expect(shape.props).toEqual(props); 183 + }); 184 + }); 185 + 186 + describe("createText", () => { 187 + it("should create a text shape without width", () => { 188 + const props: TextProps = { text: "Hello", fontSize: 16, fontFamily: "Arial", color: "#000" }; 189 + const shape = ShapeRecord.createText(pageId, 10, 20, props); 190 + 191 + expect(shape.id).toMatch(/^shape:/); 192 + expect(shape.type).toBe("text"); 193 + expect(shape.props.text).toBe("Hello"); 194 + expect(shape.props.w).toBeUndefined(); 195 + }); 196 + 197 + it("should create a text shape with width", () => { 198 + const props: TextProps = { text: "Hello", fontSize: 16, fontFamily: "Arial", color: "#000", w: 200 }; 199 + const shape = ShapeRecord.createText(pageId, 10, 20, props); 200 + 201 + expect(shape.props.w).toBe(200); 202 + }); 203 + 204 + it.each([{ text: "", fontSize: 12, fontFamily: "Arial", color: "#000" }, { 205 + text: "Multi\nline\ntext", 206 + fontSize: 24, 207 + fontFamily: "Helvetica", 208 + color: "#ff0000", 209 + }, { text: "Special chars: !@#$%^&*()", fontSize: 14, fontFamily: "Courier", color: "rgb(0,0,0)" }])( 210 + "should create text with props: %o", 211 + (props) => { 212 + const shape = ShapeRecord.createText(pageId, 0, 0, props as TextProps); 213 + expect(shape.props.text).toBe(props.text); 214 + expect(shape.props.fontSize).toBe(props.fontSize); 215 + }, 216 + ); 217 + }); 218 + 219 + describe("clone", () => { 220 + it("should clone a rect shape", () => { 221 + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }; 222 + const shape = ShapeRecord.createRect(pageId, 10, 20, props); 223 + 224 + const cloned = ShapeRecord.clone(shape); 225 + 226 + expect(cloned).toEqual(shape); 227 + expect(cloned).not.toBe(shape); 228 + expect(cloned.props).not.toBe(shape.props); 229 + }); 230 + 231 + it("should deep clone props", () => { 232 + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }; 233 + const shape = ShapeRecord.createRect(pageId, 10, 20, props); 234 + 235 + const cloned = ShapeRecord.clone(shape); 236 + if (cloned.type === "rect") { 237 + cloned.props.w = 200; 238 + } 239 + 240 + expect(shape.props.w).toBe(100); 241 + }); 242 + 243 + it("should clone line shape with Vec2 props", () => { 244 + const props: LineProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; 245 + const shape = ShapeRecord.createLine(pageId, 0, 0, props); 246 + 247 + const cloned = ShapeRecord.clone(shape); 248 + 249 + expect(cloned).toEqual(shape); 250 + expect(cloned.props).not.toBe(shape.props); 251 + }); 252 + }); 253 + 254 + describe("position and rotation", () => { 255 + it("should create shapes at different positions", () => { 256 + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }; 257 + 258 + const shape1 = ShapeRecord.createRect(pageId, 0, 0, props); 259 + const shape2 = ShapeRecord.createRect(pageId, 100, 200, props); 260 + const shape3 = ShapeRecord.createRect(pageId, -50, -30, props); 261 + 262 + expect(shape1.x).toBe(0); 263 + expect(shape1.y).toBe(0); 264 + expect(shape2.x).toBe(100); 265 + expect(shape2.y).toBe(200); 266 + expect(shape3.x).toBe(-50); 267 + expect(shape3.y).toBe(-30); 268 + }); 269 + 270 + it("should initialize rotation to 0", () => { 271 + const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }; 272 + const shape = ShapeRecord.createRect(pageId, 0, 0, props); 273 + 274 + expect(shape.rot).toBe(0); 275 + }); 276 + }); 277 + }); 278 + 279 + describe("BindingRecord", () => { 280 + describe("create", () => { 281 + it("should create a binding with default anchor", () => { 282 + const binding = BindingRecord.create("arrow1", "shape1", "start"); 283 + 284 + expect(binding.id).toMatch(/^binding:/); 285 + expect(binding.type).toBe("arrow-end"); 286 + expect(binding.fromShapeId).toBe("arrow1"); 287 + expect(binding.toShapeId).toBe("shape1"); 288 + expect(binding.handle).toBe("start"); 289 + expect(binding.anchor).toEqual({ kind: "center" }); 290 + }); 291 + 292 + it("should create a binding with custom ID", () => { 293 + const binding = BindingRecord.create("arrow1", "shape1", "end", { kind: "center" }, "binding:custom"); 294 + 295 + expect(binding.id).toBe("binding:custom"); 296 + }); 297 + 298 + it.each([{ handle: "start" as const }, { handle: "end" as const }])( 299 + "should create binding with handle: $handle", 300 + ({ handle }) => { 301 + const binding = BindingRecord.create("arrow1", "shape1", handle); 302 + expect(binding.handle).toBe(handle); 303 + }, 304 + ); 305 + 306 + it("should create binding with custom anchor", () => { 307 + const anchor = { kind: "center" as const }; 308 + const binding = BindingRecord.create("arrow1", "shape1", "start", anchor); 309 + 310 + expect(binding.anchor).toEqual(anchor); 311 + }); 312 + }); 313 + 314 + describe("clone", () => { 315 + it("should create a copy of the binding", () => { 316 + const binding = BindingRecord.create("arrow1", "shape1", "start"); 317 + 318 + const cloned = BindingRecord.clone(binding); 319 + 320 + expect(cloned).toEqual(binding); 321 + expect(cloned).not.toBe(binding); 322 + expect(cloned.anchor).not.toBe(binding.anchor); 323 + }); 324 + 325 + it("should deep clone anchor", () => { 326 + const binding = BindingRecord.create("arrow1", "shape1", "start"); 327 + 328 + const cloned = BindingRecord.clone(binding); 329 + 330 + expect(cloned.anchor).toEqual(binding.anchor); 331 + expect(cloned.anchor).not.toBe(binding.anchor); 332 + }); 333 + }); 334 + }); 335 + 336 + describe("Document", () => { 337 + describe("create", () => { 338 + it("should create an empty document", () => { 339 + const doc = Document.create(); 340 + 341 + expect(doc.pages).toEqual({}); 342 + expect(doc.shapes).toEqual({}); 343 + expect(doc.bindings).toEqual({}); 344 + }); 345 + }); 346 + 347 + describe("clone", () => { 348 + it("should clone an empty document", () => { 349 + const doc = Document.create(); 350 + const cloned = Document.clone(doc); 351 + 352 + expect(cloned).toEqual(doc); 353 + expect(cloned).not.toBe(doc); 354 + }); 355 + 356 + it("should deep clone document with pages and shapes", () => { 357 + const doc = Document.create(); 358 + const page = PageRecord.create("Page 1", "page1"); 359 + const shape = ShapeRecord.createRect( 360 + "page1", 361 + 0, 362 + 0, 363 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 364 + "shape1", 365 + ); 366 + 367 + page.shapeIds = ["shape1"]; 368 + doc.pages = { page1: page }; 369 + doc.shapes = { shape1: shape }; 370 + 371 + const cloned = Document.clone(doc); 372 + 373 + expect(cloned).toEqual(doc); 374 + expect(cloned.pages).not.toBe(doc.pages); 375 + expect(cloned.shapes).not.toBe(doc.shapes); 376 + expect(cloned.pages.page1).not.toBe(doc.pages.page1); 377 + expect(cloned.shapes.shape1).not.toBe(doc.shapes.shape1); 378 + }); 379 + 380 + it("should deep clone bindings", () => { 381 + const doc = Document.create(); 382 + const binding = BindingRecord.create("arrow1", "shape1", "start", { kind: "center" }, "binding1"); 383 + doc.bindings = { binding1: binding }; 384 + 385 + const cloned = Document.clone(doc); 386 + 387 + expect(cloned.bindings).not.toBe(doc.bindings); 388 + expect(cloned.bindings.binding1).not.toBe(doc.bindings.binding1); 389 + expect(cloned.bindings.binding1).toEqual(doc.bindings.binding1); 390 + }); 391 + }); 392 + }); 393 + 394 + describe("validateDoc", () => { 395 + describe("valid documents", () => { 396 + it("should validate empty document", () => { 397 + const doc = Document.create(); 398 + const result = validateDoc(doc); 399 + 400 + expect(result.ok).toBe(true); 401 + }); 402 + 403 + it("should validate document with page and shape", () => { 404 + const doc = Document.create(); 405 + const page = PageRecord.create("Page 1", "page1"); 406 + const shape = ShapeRecord.createRect( 407 + "page1", 408 + 0, 409 + 0, 410 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 411 + "shape1", 412 + ); 413 + 414 + page.shapeIds = ["shape1"]; 415 + doc.pages = { page1: page }; 416 + doc.shapes = { shape1: shape }; 417 + 418 + const result = validateDoc(doc); 419 + 420 + expect(result.ok).toBe(true); 421 + }); 422 + 423 + it("should validate document with multiple shapes", () => { 424 + const doc = Document.create(); 425 + const page = PageRecord.create("Page 1", "page1"); 426 + const shape1 = ShapeRecord.createRect( 427 + "page1", 428 + 0, 429 + 0, 430 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 431 + "shape1", 432 + ); 433 + const shape2 = ShapeRecord.createEllipse( 434 + "page1", 435 + 50, 436 + 50, 437 + { w: 75, h: 75, fill: "#000", stroke: "#fff" }, 438 + "shape2", 439 + ); 440 + 441 + page.shapeIds = ["shape1", "shape2"]; 442 + doc.pages = { page1: page }; 443 + doc.shapes = { shape1, shape2 }; 444 + 445 + const result = validateDoc(doc); 446 + 447 + expect(result.ok).toBe(true); 448 + }); 449 + 450 + it("should validate document with binding", () => { 451 + const doc = Document.create(); 452 + const page = PageRecord.create("Page 1", "page1"); 453 + const arrow = ShapeRecord.createArrow("page1", 0, 0, { 454 + a: { x: 0, y: 0 }, 455 + b: { x: 100, y: 0 }, 456 + stroke: "#000", 457 + width: 2, 458 + }, "arrow1"); 459 + const rect = ShapeRecord.createRect( 460 + "page1", 461 + 100, 462 + 0, 463 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 464 + "rect1", 465 + ); 466 + const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "center" }, "binding1"); 467 + 468 + page.shapeIds = ["arrow1", "rect1"]; 469 + doc.pages = { page1: page }; 470 + doc.shapes = { arrow1: arrow, rect1: rect }; 471 + doc.bindings = { binding1: binding }; 472 + 473 + const result = validateDoc(doc); 474 + 475 + expect(result.ok).toBe(true); 476 + }); 477 + }); 478 + 479 + describe("invalid documents", () => { 480 + it("should reject document with shapes but no pages", () => { 481 + const doc = Document.create(); 482 + const shape = ShapeRecord.createRect( 483 + "page1", 484 + 0, 485 + 0, 486 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 487 + "shape1", 488 + ); 489 + doc.shapes = { shape1: shape }; 490 + 491 + const result = validateDoc(doc); 492 + 493 + expect(result.ok).toBe(false); 494 + if (!result.ok) { 495 + expect(result.errors).toContain("Document has shapes but no pages"); 496 + } 497 + }); 498 + 499 + it("should reject shape with mismatched ID", () => { 500 + const doc = Document.create(); 501 + const page = PageRecord.create("Page 1", "page1"); 502 + const shape = ShapeRecord.createRect( 503 + "page1", 504 + 0, 505 + 0, 506 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 507 + "shape1", 508 + ); 509 + 510 + page.shapeIds = ["shape1"]; 511 + doc.pages = { page1: page }; 512 + doc.shapes = { wrongId: shape }; 513 + 514 + const result = validateDoc(doc); 515 + 516 + expect(result.ok).toBe(false); 517 + if (!result.ok) { 518 + expect(result.errors).toContain("Shape key 'wrongId' does not match shape.id 'shape1'"); 519 + } 520 + }); 521 + 522 + it("should reject shape referencing non-existent page", () => { 523 + const doc = Document.create(); 524 + const shape = ShapeRecord.createRect("nonexistent", 0, 0, { 525 + w: 100, 526 + h: 50, 527 + fill: "#fff", 528 + stroke: "#000", 529 + radius: 0, 530 + }, "shape1"); 531 + 532 + doc.shapes = { shape1: shape }; 533 + 534 + const result = validateDoc(doc); 535 + 536 + expect(result.ok).toBe(false); 537 + if (!result.ok) { 538 + expect(result.errors).toContain("Shape 'shape1' references non-existent page 'nonexistent'"); 539 + } 540 + }); 541 + 542 + it("should reject shape not listed in page shapeIds", () => { 543 + const doc = Document.create(); 544 + const page = PageRecord.create("Page 1", "page1"); 545 + const shape = ShapeRecord.createRect( 546 + "page1", 547 + 0, 548 + 0, 549 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 550 + "shape1", 551 + ); 552 + 553 + doc.pages = { page1: page }; 554 + doc.shapes = { shape1: shape }; 555 + 556 + const result = validateDoc(doc); 557 + 558 + expect(result.ok).toBe(false); 559 + if (!result.ok) { 560 + expect(result.errors).toContain("Shape 'shape1' not listed in page 'page1' shapeIds"); 561 + } 562 + }); 563 + 564 + it("should reject page referencing non-existent shape", () => { 565 + const doc = Document.create(); 566 + const page = PageRecord.create("Page 1", "page1"); 567 + 568 + page.shapeIds = ["nonexistent"]; 569 + doc.pages = { page1: page }; 570 + 571 + const result = validateDoc(doc); 572 + 573 + expect(result.ok).toBe(false); 574 + if (!result.ok) { 575 + expect(result.errors).toContain("Page 'page1' references non-existent shape 'nonexistent'"); 576 + } 577 + }); 578 + 579 + it("should reject page with duplicate shape IDs", () => { 580 + const doc = Document.create(); 581 + const page = PageRecord.create("Page 1", "page1"); 582 + const shape = ShapeRecord.createRect( 583 + "page1", 584 + 0, 585 + 0, 586 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 587 + "shape1", 588 + ); 589 + 590 + page.shapeIds = ["shape1", "shape1"]; 591 + doc.pages = { page1: page }; 592 + doc.shapes = { shape1: shape }; 593 + 594 + const result = validateDoc(doc); 595 + 596 + expect(result.ok).toBe(false); 597 + if (!result.ok) { 598 + expect(result.errors).toContain("Page 'page1' has duplicate shape IDs"); 599 + } 600 + }); 601 + 602 + it("should reject binding to non-existent fromShape", () => { 603 + const doc = Document.create(); 604 + const page = PageRecord.create("Page 1", "page1"); 605 + const rect = ShapeRecord.createRect( 606 + "page1", 607 + 0, 608 + 0, 609 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 610 + "rect1", 611 + ); 612 + const binding = BindingRecord.create("nonexistent", "rect1", "end", { kind: "center" }, "binding1"); 613 + 614 + page.shapeIds = ["rect1"]; 615 + doc.pages = { page1: page }; 616 + doc.shapes = { rect1: rect }; 617 + doc.bindings = { binding1: binding }; 618 + 619 + const result = validateDoc(doc); 620 + 621 + expect(result.ok).toBe(false); 622 + if (!result.ok) { 623 + expect(result.errors).toContain("Binding 'binding1' references non-existent fromShape 'nonexistent'"); 624 + } 625 + }); 626 + 627 + it("should reject binding to non-existent toShape", () => { 628 + const doc = Document.create(); 629 + const page = PageRecord.create("Page 1", "page1"); 630 + const arrow = ShapeRecord.createArrow("page1", 0, 0, { 631 + a: { x: 0, y: 0 }, 632 + b: { x: 100, y: 0 }, 633 + stroke: "#000", 634 + width: 2, 635 + }, "arrow1"); 636 + const binding = BindingRecord.create("arrow1", "nonexistent", "end", { kind: "center" }, "binding1"); 637 + 638 + page.shapeIds = ["arrow1"]; 639 + doc.pages = { page1: page }; 640 + doc.shapes = { arrow1: arrow }; 641 + doc.bindings = { binding1: binding }; 642 + 643 + const result = validateDoc(doc); 644 + 645 + expect(result.ok).toBe(false); 646 + if (!result.ok) { 647 + expect(result.errors).toContain("Binding 'binding1' references non-existent toShape 'nonexistent'"); 648 + } 649 + }); 650 + 651 + it("should reject binding from non-arrow shape", () => { 652 + const doc = Document.create(); 653 + const page = PageRecord.create("Page 1", "page1"); 654 + const rect1 = ShapeRecord.createRect( 655 + "page1", 656 + 0, 657 + 0, 658 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 659 + "rect1", 660 + ); 661 + const rect2 = ShapeRecord.createRect( 662 + "page1", 663 + 100, 664 + 0, 665 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 666 + "rect2", 667 + ); 668 + const binding = BindingRecord.create("rect1", "rect2", "start", { kind: "center" }, "binding1"); 669 + 670 + page.shapeIds = ["rect1", "rect2"]; 671 + doc.pages = { page1: page }; 672 + doc.shapes = { rect1, rect2 }; 673 + doc.bindings = { binding1: binding }; 674 + 675 + const result = validateDoc(doc); 676 + 677 + expect(result.ok).toBe(false); 678 + if (!result.ok) { 679 + expect(result.errors).toContain("Binding 'binding1' fromShape 'rect1' is not an arrow"); 680 + } 681 + }); 682 + 683 + it("should reject rect with negative width", () => { 684 + const doc = Document.create(); 685 + const page = PageRecord.create("Page 1", "page1"); 686 + const shape = ShapeRecord.createRect( 687 + "page1", 688 + 0, 689 + 0, 690 + { w: -100, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 691 + "shape1", 692 + ); 693 + 694 + page.shapeIds = ["shape1"]; 695 + doc.pages = { page1: page }; 696 + doc.shapes = { shape1: shape }; 697 + 698 + const result = validateDoc(doc); 699 + 700 + expect(result.ok).toBe(false); 701 + if (!result.ok) { 702 + expect(result.errors).toContain("Rect shape 'shape1' has negative width"); 703 + } 704 + }); 705 + 706 + it("should reject rect with negative height", () => { 707 + const doc = Document.create(); 708 + const page = PageRecord.create("Page 1", "page1"); 709 + const shape = ShapeRecord.createRect( 710 + "page1", 711 + 0, 712 + 0, 713 + { w: 100, h: -50, fill: "#fff", stroke: "#000", radius: 0 }, 714 + "shape1", 715 + ); 716 + 717 + page.shapeIds = ["shape1"]; 718 + doc.pages = { page1: page }; 719 + doc.shapes = { shape1: shape }; 720 + 721 + const result = validateDoc(doc); 722 + 723 + expect(result.ok).toBe(false); 724 + if (!result.ok) { 725 + expect(result.errors).toContain("Rect shape 'shape1' has negative height"); 726 + } 727 + }); 728 + 729 + it("should reject rect with negative radius", () => { 730 + const doc = Document.create(); 731 + const page = PageRecord.create("Page 1", "page1"); 732 + const shape = ShapeRecord.createRect( 733 + "page1", 734 + 0, 735 + 0, 736 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: -5 }, 737 + "shape1", 738 + ); 739 + 740 + page.shapeIds = ["shape1"]; 741 + doc.pages = { page1: page }; 742 + doc.shapes = { shape1: shape }; 743 + 744 + const result = validateDoc(doc); 745 + 746 + expect(result.ok).toBe(false); 747 + if (!result.ok) { 748 + expect(result.errors).toContain("Rect shape 'shape1' has negative radius"); 749 + } 750 + }); 751 + 752 + it("should reject ellipse with negative dimensions", () => { 753 + const doc = Document.create(); 754 + const page = PageRecord.create("Page 1", "page1"); 755 + const shape = ShapeRecord.createEllipse( 756 + "page1", 757 + 0, 758 + 0, 759 + { w: -100, h: 50, fill: "#fff", stroke: "#000" }, 760 + "shape1", 761 + ); 762 + 763 + page.shapeIds = ["shape1"]; 764 + doc.pages = { page1: page }; 765 + doc.shapes = { shape1: shape }; 766 + 767 + const result = validateDoc(doc); 768 + 769 + expect(result.ok).toBe(false); 770 + if (!result.ok) { 771 + expect(result.errors).toContain("Ellipse shape 'shape1' has negative width"); 772 + } 773 + }); 774 + 775 + it("should reject line with negative width", () => { 776 + const doc = Document.create(); 777 + const page = PageRecord.create("Page 1", "page1"); 778 + const shape = ShapeRecord.createLine("page1", 0, 0, { 779 + a: { x: 0, y: 0 }, 780 + b: { x: 100, y: 0 }, 781 + stroke: "#000", 782 + width: -2, 783 + }, "shape1"); 784 + 785 + page.shapeIds = ["shape1"]; 786 + doc.pages = { page1: page }; 787 + doc.shapes = { shape1: shape }; 788 + 789 + const result = validateDoc(doc); 790 + 791 + expect(result.ok).toBe(false); 792 + if (!result.ok) { 793 + expect(result.errors).toContain("line shape 'shape1' has negative width"); 794 + } 795 + }); 796 + 797 + it("should reject text with invalid fontSize", () => { 798 + const doc = Document.create(); 799 + const page = PageRecord.create("Page 1", "page1"); 800 + const shape = ShapeRecord.createText("page1", 0, 0, { 801 + text: "Test", 802 + fontSize: 0, 803 + fontFamily: "Arial", 804 + color: "#000", 805 + }, "shape1"); 806 + 807 + page.shapeIds = ["shape1"]; 808 + doc.pages = { page1: page }; 809 + doc.shapes = { shape1: shape }; 810 + 811 + const result = validateDoc(doc); 812 + 813 + expect(result.ok).toBe(false); 814 + if (!result.ok) { 815 + expect(result.errors).toContain("Text shape 'shape1' has invalid fontSize"); 816 + } 817 + }); 818 + 819 + it("should reject text with negative width", () => { 820 + const doc = Document.create(); 821 + const page = PageRecord.create("Page 1", "page1"); 822 + const shape = ShapeRecord.createText("page1", 0, 0, { 823 + text: "Test", 824 + fontSize: 12, 825 + fontFamily: "Arial", 826 + color: "#000", 827 + w: -100, 828 + }, "shape1"); 829 + 830 + page.shapeIds = ["shape1"]; 831 + doc.pages = { page1: page }; 832 + doc.shapes = { shape1: shape }; 833 + 834 + const result = validateDoc(doc); 835 + 836 + expect(result.ok).toBe(false); 837 + if (!result.ok) { 838 + expect(result.errors).toContain("Text shape 'shape1' has negative width"); 839 + } 840 + }); 841 + 842 + it("should collect multiple errors", () => { 843 + const doc = Document.create(); 844 + const page = PageRecord.create("Page 1", "page1"); 845 + const shape1 = ShapeRecord.createRect( 846 + "page1", 847 + 0, 848 + 0, 849 + { w: -100, h: -50, fill: "#fff", stroke: "#000", radius: 0 }, 850 + "shape1", 851 + ); 852 + const shape2 = ShapeRecord.createRect("nonexistent", 0, 0, { 853 + w: 100, 854 + h: 50, 855 + fill: "#fff", 856 + stroke: "#000", 857 + radius: 0, 858 + }, "shape2"); 859 + 860 + page.shapeIds = ["shape1"]; 861 + doc.pages = { page1: page }; 862 + doc.shapes = { shape1, shape2 }; 863 + 864 + const result = validateDoc(doc); 865 + 866 + expect(result.ok).toBe(false); 867 + if (!result.ok) { 868 + expect(result.errors.length).toBeGreaterThan(1); 869 + } 870 + }); 871 + }); 872 + 873 + describe("edge cases", () => { 874 + it("should accept zero-sized shapes", () => { 875 + const doc = Document.create(); 876 + const page = PageRecord.create("Page 1", "page1"); 877 + const shape = ShapeRecord.createRect( 878 + "page1", 879 + 0, 880 + 0, 881 + { w: 0, h: 0, fill: "#fff", stroke: "#000", radius: 0 }, 882 + "shape1", 883 + ); 884 + 885 + page.shapeIds = ["shape1"]; 886 + doc.pages = { page1: page }; 887 + doc.shapes = { shape1: shape }; 888 + 889 + const result = validateDoc(doc); 890 + 891 + expect(result.ok).toBe(true); 892 + }); 893 + 894 + it("should accept text with undefined width", () => { 895 + const doc = Document.create(); 896 + const page = PageRecord.create("Page 1", "page1"); 897 + const shape = ShapeRecord.createText("page1", 0, 0, { 898 + text: "Test", 899 + fontSize: 12, 900 + fontFamily: "Arial", 901 + color: "#000", 902 + }, "shape1"); 903 + 904 + page.shapeIds = ["shape1"]; 905 + doc.pages = { page1: page }; 906 + doc.shapes = { shape1: shape }; 907 + 908 + const result = validateDoc(doc); 909 + 910 + expect(result.ok).toBe(true); 911 + }); 912 + 913 + it("should accept empty page name", () => { 914 + const doc = Document.create(); 915 + const page = PageRecord.create("", "page1"); 916 + doc.pages = { page1: page }; 917 + 918 + const result = validateDoc(doc); 919 + 920 + expect(result.ok).toBe(true); 921 + }); 922 + }); 923 + }); 924 + 925 + describe("JSON serialization", () => { 926 + it("should round-trip empty document", () => { 927 + const doc = Document.create(); 928 + const json = JSON.stringify(doc); 929 + const parsed = JSON.parse(json); 930 + 931 + expect(parsed).toEqual(doc); 932 + expect(validateDoc(parsed).ok).toBe(true); 933 + }); 934 + 935 + it("should round-trip document with page and shape", () => { 936 + const doc = Document.create(); 937 + const page = PageRecord.create("Page 1", "page1"); 938 + const shape = ShapeRecord.createRect( 939 + "page1", 940 + 10, 941 + 20, 942 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }, 943 + "shape1", 944 + ); 945 + 946 + page.shapeIds = ["shape1"]; 947 + doc.pages = { page1: page }; 948 + doc.shapes = { shape1: shape }; 949 + 950 + const json = JSON.stringify(doc); 951 + const parsed = JSON.parse(json); 952 + 953 + expect(parsed).toEqual(doc); 954 + expect(validateDoc(parsed).ok).toBe(true); 955 + }); 956 + 957 + it("should round-trip document with all shape types", () => { 958 + const doc = Document.create(); 959 + const page = PageRecord.create("Page 1", "page1"); 960 + 961 + const rect = ShapeRecord.createRect( 962 + "page1", 963 + 0, 964 + 0, 965 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }, 966 + "shape1", 967 + ); 968 + const ellipse = ShapeRecord.createEllipse( 969 + "page1", 970 + 100, 971 + 100, 972 + { w: 75, h: 75, fill: "#f00", stroke: "#000" }, 973 + "shape2", 974 + ); 975 + const line = ShapeRecord.createLine("page1", 200, 200, { 976 + a: { x: 0, y: 0 }, 977 + b: { x: 100, y: 50 }, 978 + stroke: "#000", 979 + width: 2, 980 + }, "shape3"); 981 + const arrow = ShapeRecord.createArrow("page1", 300, 300, { 982 + a: { x: 0, y: 0 }, 983 + b: { x: 100, y: 0 }, 984 + stroke: "#000", 985 + width: 2, 986 + }, "shape4"); 987 + const text = ShapeRecord.createText("page1", 400, 400, { 988 + text: "Hello World", 989 + fontSize: 16, 990 + fontFamily: "Arial", 991 + color: "#000", 992 + w: 200, 993 + }, "shape5"); 994 + 995 + page.shapeIds = ["shape1", "shape2", "shape3", "shape4", "shape5"]; 996 + doc.pages = { page1: page }; 997 + doc.shapes = { shape1: rect, shape2: ellipse, shape3: line, shape4: arrow, shape5: text }; 998 + 999 + const json = JSON.stringify(doc); 1000 + const parsed = JSON.parse(json); 1001 + 1002 + expect(parsed).toEqual(doc); 1003 + expect(validateDoc(parsed).ok).toBe(true); 1004 + }); 1005 + 1006 + it("should round-trip document with bindings", () => { 1007 + const doc = Document.create(); 1008 + const page = PageRecord.create("Page 1", "page1"); 1009 + const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1010 + a: { x: 0, y: 0 }, 1011 + b: { x: 100, y: 0 }, 1012 + stroke: "#000", 1013 + width: 2, 1014 + }, "arrow1"); 1015 + const rect = ShapeRecord.createRect( 1016 + "page1", 1017 + 100, 1018 + 0, 1019 + { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 }, 1020 + "rect1", 1021 + ); 1022 + const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "center" }, "binding1"); 1023 + 1024 + page.shapeIds = ["arrow1", "rect1"]; 1025 + doc.pages = { page1: page }; 1026 + doc.shapes = { arrow1: arrow, rect1: rect }; 1027 + doc.bindings = { binding1: binding }; 1028 + 1029 + const json = JSON.stringify(doc); 1030 + const parsed = JSON.parse(json); 1031 + 1032 + expect(parsed).toEqual(doc); 1033 + expect(validateDoc(parsed).ok).toBe(true); 1034 + }); 1035 + 1036 + it("should round-trip complex document", () => { 1037 + const doc = Document.create(); 1038 + const page1 = PageRecord.create("Page 1", "page1"); 1039 + const page2 = PageRecord.create("Page 2", "page2"); 1040 + 1041 + const shape1 = ShapeRecord.createRect( 1042 + "page1", 1043 + 0, 1044 + 0, 1045 + { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 }, 1046 + "shape1", 1047 + ); 1048 + const shape2 = ShapeRecord.createEllipse( 1049 + "page1", 1050 + 100, 1051 + 100, 1052 + { w: 75, h: 75, fill: "#f00", stroke: "#000" }, 1053 + "shape2", 1054 + ); 1055 + const shape3 = ShapeRecord.createArrow("page2", 0, 0, { 1056 + a: { x: 0, y: 0 }, 1057 + b: { x: 100, y: 0 }, 1058 + stroke: "#000", 1059 + width: 2, 1060 + }, "shape3"); 1061 + const shape4 = ShapeRecord.createRect( 1062 + "page2", 1063 + 100, 1064 + 0, 1065 + { w: 50, h: 50, fill: "#0f0", stroke: "#000", radius: 0 }, 1066 + "shape4", 1067 + ); 1068 + 1069 + const binding = BindingRecord.create("shape3", "shape4", "end", { kind: "center" }, "binding1"); 1070 + 1071 + page1.shapeIds = ["shape1", "shape2"]; 1072 + page2.shapeIds = ["shape3", "shape4"]; 1073 + 1074 + doc.pages = { page1, page2 }; 1075 + doc.shapes = { shape1, shape2, shape3, shape4 }; 1076 + doc.bindings = { binding1: binding }; 1077 + 1078 + const json = JSON.stringify(doc); 1079 + const parsed = JSON.parse(json); 1080 + 1081 + expect(parsed).toEqual(doc); 1082 + expect(validateDoc(parsed).ok).toBe(true); 1083 + }); 1084 + });
+11 -2
pnpm-lock.yaml
··· 32 32 33 33 packages/core: 34 34 dependencies: 35 + rxjs: 36 + specifier: ^7.8.2 37 + version: 7.8.2 35 38 uuid: 36 39 specifier: ^13.0.0 37 40 version: 13.0.0 ··· 1228 1231 resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} 1229 1232 engines: {node: '>=18.0.0', npm: '>=8.0.0'} 1230 1233 hasBin: true 1234 + 1235 + rxjs@7.8.2: 1236 + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} 1231 1237 1232 1238 semver@7.7.3: 1233 1239 resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} ··· 2536 2542 '@rollup/rollup-win32-x64-msvc': 4.54.0 2537 2543 fsevents: 2.3.3 2538 2544 2545 + rxjs@7.8.2: 2546 + dependencies: 2547 + tslib: 2.8.1 2548 + 2539 2549 semver@7.7.3: {} 2540 2550 2541 2551 shebang-command@2.0.0: ··· 2604 2614 - synckit 2605 2615 - vue-tsc 2606 2616 2607 - tslib@2.8.1: 2608 - optional: true 2617 + tslib@2.8.1: {} 2609 2618 2610 2619 type-check@0.4.0: 2611 2620 dependencies: