web based infinite canvas
1import { describe, expect, it } from "vitest";
2import {
3 type ArrowProps,
4 type ArrowStyle,
5 BindingRecord,
6 createId,
7 Document,
8 type EllipseProps,
9 type LineProps,
10 PageRecord,
11 type RectProps,
12 ShapeRecord,
13 type TextProps,
14 validateDoc,
15} from "../src/model";
16
17describe("createId", () => {
18 it("should generate a valid UUID without prefix", () => {
19 const id = createId();
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
53describe("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
102describe("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 with modern format (points only)", () => {
177 const props: ArrowProps = {
178 points: [{ x: 0, y: 0 }, { x: 100, y: 50 }],
179 start: { kind: "free" },
180 end: { kind: "free" },
181 style: { stroke: "#000", width: 2 },
182 };
183 const shape = ShapeRecord.createArrow(pageId, 10, 20, props);
184
185 expect(shape.id).toMatch(/^shape:/);
186 expect(shape.type).toBe("arrow");
187 expect(shape.props.points).toEqual(props.points);
188 expect(shape.props.start).toEqual({ kind: "free" });
189 expect(shape.props.end).toEqual({ kind: "free" });
190 expect(shape.props.style).toEqual({ stroke: "#000", width: 2 });
191 });
192
193 it("should create an arrow with polyline (3+ points)", () => {
194 const props: ArrowProps = {
195 points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }],
196 start: { kind: "free" },
197 end: { kind: "free" },
198 style: { stroke: "#ff0000", width: 3 },
199 };
200 const shape = ShapeRecord.createArrow(pageId, 0, 0, props);
201
202 expect(shape.props.points?.length).toBe(3);
203 expect(shape.props.points).toEqual(props.points);
204 });
205
206 it("should create an arrow with bound endpoints", () => {
207 const props: ArrowProps = {
208 points: [{ x: 0, y: 0 }, { x: 100, y: 50 }],
209 start: { kind: "bound", bindingId: "binding:1" },
210 end: { kind: "bound", bindingId: "binding:2" },
211 style: { stroke: "#000", width: 2 },
212 };
213 const shape = ShapeRecord.createArrow(pageId, 0, 0, props);
214
215 expect(shape.props.start).toEqual({ kind: "bound", bindingId: "binding:1" });
216 expect(shape.props.end).toEqual({ kind: "bound", bindingId: "binding:2" });
217 });
218
219 it("should create an arrow with arrowheads", () => {
220 const style: ArrowStyle = { stroke: "#000", width: 2, headStart: true, headEnd: true };
221 const props: ArrowProps = {
222 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
223 start: { kind: "free" },
224 end: { kind: "free" },
225 style,
226 };
227 const shape = ShapeRecord.createArrow(pageId, 0, 0, props);
228
229 expect(shape.props.style?.headStart).toBe(true);
230 expect(shape.props.style?.headEnd).toBe(true);
231 });
232
233 it("should create an arrow with dash pattern", () => {
234 const style: ArrowStyle = { stroke: "#000", width: 2, dash: [5, 3] };
235 const props: ArrowProps = {
236 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
237 start: { kind: "free" },
238 end: { kind: "free" },
239 style,
240 };
241 const shape = ShapeRecord.createArrow(pageId, 0, 0, props);
242
243 expect(shape.props.style?.dash).toEqual([5, 3]);
244 });
245
246 it("should create an arrow with orthogonal routing", () => {
247 const props: ArrowProps = {
248 points: [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 50, y: 50 }, { x: 100, y: 50 }],
249 start: { kind: "free" },
250 end: { kind: "free" },
251 style: { stroke: "#000", width: 2 },
252 routing: { kind: "orthogonal", cornerRadius: 5 },
253 };
254 const shape = ShapeRecord.createArrow(pageId, 0, 0, props);
255
256 expect(shape.props.routing).toEqual({ kind: "orthogonal", cornerRadius: 5 });
257 });
258
259 it("should create an arrow with label", () => {
260 const props: ArrowProps = {
261 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
262 start: { kind: "free" },
263 end: { kind: "free" },
264 style: { stroke: "#000", width: 2 },
265 label: { text: "Connection", align: "center", offset: 0 },
266 };
267 const shape = ShapeRecord.createArrow(pageId, 0, 0, props);
268
269 expect(shape.props.label).toEqual({ text: "Connection", align: "center", offset: 0 });
270 });
271
272 it.each([{ align: "center" as const, offset: 0 }, { align: "start" as const, offset: 10 }, {
273 align: "end" as const,
274 offset: -10,
275 }])("should create arrow with label alignment: $align", ({ align, offset }) => {
276 const props: ArrowProps = {
277 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
278 start: { kind: "free" },
279 end: { kind: "free" },
280 style: { stroke: "#000", width: 2 },
281 label: { text: "Test", align, offset },
282 };
283 const shape = ShapeRecord.createArrow(pageId, 0, 0, props);
284
285 expect(shape.props.label?.align).toBe(align);
286 expect(shape.props.label?.offset).toBe(offset);
287 });
288 });
289
290 describe("createText", () => {
291 it("should create a text shape without width", () => {
292 const props: TextProps = { text: "Hello", fontSize: 16, fontFamily: "Arial", color: "#000" };
293 const shape = ShapeRecord.createText(pageId, 10, 20, props);
294
295 expect(shape.id).toMatch(/^shape:/);
296 expect(shape.type).toBe("text");
297 expect(shape.props.text).toBe("Hello");
298 expect(shape.props.w).toBeUndefined();
299 });
300
301 it("should create a text shape with width", () => {
302 const props: TextProps = { text: "Hello", fontSize: 16, fontFamily: "Arial", color: "#000", w: 200 };
303 const shape = ShapeRecord.createText(pageId, 10, 20, props);
304
305 expect(shape.props.w).toBe(200);
306 });
307
308 it.each([{ text: "", fontSize: 12, fontFamily: "Arial", color: "#000" }, {
309 text: "Multi\nline\ntext",
310 fontSize: 24,
311 fontFamily: "Helvetica",
312 color: "#ff0000",
313 }, { text: "Special chars: !@#$%^&*()", fontSize: 14, fontFamily: "Courier", color: "rgb(0,0,0)" }])(
314 "should create text with props: %o",
315 (props) => {
316 const shape = ShapeRecord.createText(pageId, 0, 0, props as TextProps);
317 expect(shape.props.text).toBe(props.text);
318 expect(shape.props.fontSize).toBe(props.fontSize);
319 },
320 );
321 });
322
323 describe("clone", () => {
324 it("should clone a rect shape", () => {
325 const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 };
326 const shape = ShapeRecord.createRect(pageId, 10, 20, props);
327
328 const cloned = ShapeRecord.clone(shape);
329
330 expect(cloned).toEqual(shape);
331 expect(cloned).not.toBe(shape);
332 expect(cloned.props).not.toBe(shape.props);
333 });
334
335 it("should deep clone props", () => {
336 const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 };
337 const shape = ShapeRecord.createRect(pageId, 10, 20, props);
338
339 const cloned = ShapeRecord.clone(shape);
340 if (cloned.type === "rect") {
341 cloned.props.w = 200;
342 }
343
344 expect(shape.props.w).toBe(100);
345 });
346
347 it("should clone line shape with Vec2 props", () => {
348 const props: LineProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 };
349 const shape = ShapeRecord.createLine(pageId, 0, 0, props);
350
351 const cloned = ShapeRecord.clone(shape);
352
353 expect(cloned).toEqual(shape);
354 expect(cloned.props).not.toBe(shape.props);
355 });
356
357 it("should clone modern arrow shape with points", () => {
358 const props: ArrowProps = {
359 points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }],
360 start: { kind: "free" },
361 end: { kind: "bound", bindingId: "binding:1" },
362 style: { stroke: "#000", width: 2, dash: [5, 3] },
363 routing: { kind: "orthogonal", cornerRadius: 5 },
364 label: { text: "Test", align: "center", offset: 0 },
365 };
366 const shape = ShapeRecord.createArrow(pageId, 0, 0, props);
367
368 const cloned = ShapeRecord.clone(shape);
369
370 expect(cloned).toEqual(shape);
371 expect(cloned.props).not.toBe(shape.props);
372 if (cloned.type === "arrow" && shape.type === "arrow") {
373 expect(cloned.props.points).not.toBe(shape.props.points);
374 expect(cloned.props.start).not.toBe(shape.props.start);
375 expect(cloned.props.end).not.toBe(shape.props.end);
376 expect(cloned.props.style).not.toBe(shape.props.style);
377 expect(cloned.props.routing).not.toBe(shape.props.routing);
378 expect(cloned.props.label).not.toBe(shape.props.label);
379 }
380 });
381
382 it("should deep clone arrow points array", () => {
383 const props: ArrowProps = {
384 points: [{ x: 0, y: 0 }, { x: 100, y: 50 }],
385 start: { kind: "free" },
386 end: { kind: "free" },
387 style: { stroke: "#000", width: 2 },
388 };
389 const shape = ShapeRecord.createArrow(pageId, 0, 0, props);
390
391 const cloned = ShapeRecord.clone(shape);
392
393 if (cloned.type === "arrow" && shape.type === "arrow" && cloned.props.points && shape.props.points) {
394 cloned.props.points[0].x = 999;
395 expect(shape.props.points[0].x).toBe(0);
396 }
397 });
398
399 it("should deep clone arrow style dash array", () => {
400 const props: ArrowProps = {
401 points: [{ x: 0, y: 0 }, { x: 100, y: 50 }],
402 start: { kind: "free" },
403 end: { kind: "free" },
404 style: { stroke: "#000", width: 2, dash: [5, 3] },
405 };
406 const shape = ShapeRecord.createArrow(pageId, 0, 0, props);
407
408 const cloned = ShapeRecord.clone(shape);
409
410 if (cloned.type === "arrow" && shape.type === "arrow" && cloned.props.style?.dash && shape.props.style?.dash) {
411 cloned.props.style.dash[0] = 999;
412 expect(shape.props.style.dash[0]).toBe(5);
413 }
414 });
415 });
416
417 describe("position and rotation", () => {
418 it("should create shapes at different positions", () => {
419 const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 };
420
421 const shape1 = ShapeRecord.createRect(pageId, 0, 0, props);
422 const shape2 = ShapeRecord.createRect(pageId, 100, 200, props);
423 const shape3 = ShapeRecord.createRect(pageId, -50, -30, props);
424
425 expect(shape1.x).toBe(0);
426 expect(shape1.y).toBe(0);
427 expect(shape2.x).toBe(100);
428 expect(shape2.y).toBe(200);
429 expect(shape3.x).toBe(-50);
430 expect(shape3.y).toBe(-30);
431 });
432
433 it("should initialize rotation to 0", () => {
434 const props: RectProps = { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 };
435 const shape = ShapeRecord.createRect(pageId, 0, 0, props);
436
437 expect(shape.rot).toBe(0);
438 });
439 });
440});
441
442describe("BindingRecord", () => {
443 describe("create", () => {
444 it("should create a binding with default anchor", () => {
445 const binding = BindingRecord.create("arrow1", "shape1", "start");
446
447 expect(binding.id).toMatch(/^binding:/);
448 expect(binding.type).toBe("arrow-end");
449 expect(binding.fromShapeId).toBe("arrow1");
450 expect(binding.toShapeId).toBe("shape1");
451 expect(binding.handle).toBe("start");
452 expect(binding.anchor).toEqual({ kind: "center" });
453 });
454
455 it("should create a binding with custom ID", () => {
456 const binding = BindingRecord.create("arrow1", "shape1", "end", { kind: "center" }, "binding:custom");
457
458 expect(binding.id).toBe("binding:custom");
459 });
460
461 it.each([{ handle: "start" as const }, { handle: "end" as const }])(
462 "should create binding with handle: $handle",
463 ({ handle }) => {
464 const binding = BindingRecord.create("arrow1", "shape1", handle);
465 expect(binding.handle).toBe(handle);
466 },
467 );
468
469 it("should create binding with custom anchor", () => {
470 const anchor = { kind: "center" as const };
471 const binding = BindingRecord.create("arrow1", "shape1", "start", anchor);
472
473 expect(binding.anchor).toEqual(anchor);
474 });
475 });
476
477 describe("clone", () => {
478 it("should create a copy of the binding with center anchor", () => {
479 const binding = BindingRecord.create("arrow1", "shape1", "start");
480
481 const cloned = BindingRecord.clone(binding);
482
483 expect(cloned).toEqual(binding);
484 expect(cloned).not.toBe(binding);
485 expect(cloned.anchor).not.toBe(binding.anchor);
486 });
487
488 it("should deep clone center anchor", () => {
489 const binding = BindingRecord.create("arrow1", "shape1", "start");
490
491 const cloned = BindingRecord.clone(binding);
492
493 expect(cloned.anchor).toEqual(binding.anchor);
494 expect(cloned.anchor).not.toBe(binding.anchor);
495 });
496
497 it("should clone binding with edge anchor", () => {
498 const binding = BindingRecord.create("arrow1", "shape1", "end", { kind: "edge", nx: 0.5, ny: -0.5 });
499
500 const cloned = BindingRecord.clone(binding);
501
502 expect(cloned).toEqual(binding);
503 expect(cloned).not.toBe(binding);
504 expect(cloned.anchor).not.toBe(binding.anchor);
505 });
506
507 it("should deep clone edge anchor", () => {
508 const binding = BindingRecord.create("arrow1", "shape1", "start", { kind: "edge", nx: 1, ny: 0 });
509
510 const cloned = BindingRecord.clone(binding);
511
512 expect(cloned.anchor).toEqual({ kind: "edge", nx: 1, ny: 0 });
513 expect(cloned.anchor).not.toBe(binding.anchor);
514 });
515 });
516
517 describe("edge anchors", () => {
518 it("should create binding with edge anchor at right edge", () => {
519 const anchor = { kind: "edge" as const, nx: 1, ny: 0 };
520 const binding = BindingRecord.create("arrow1", "shape1", "start", anchor);
521
522 expect(binding.anchor).toEqual({ kind: "edge", nx: 1, ny: 0 });
523 });
524
525 it("should create binding with edge anchor at top-left corner", () => {
526 const anchor = { kind: "edge" as const, nx: -1, ny: -1 };
527 const binding = BindingRecord.create("arrow1", "shape1", "end", anchor);
528
529 expect(binding.anchor).toEqual({ kind: "edge", nx: -1, ny: -1 });
530 });
531
532 it.each([
533 { nx: 0, ny: 0, desc: "center" },
534 { nx: 1, ny: 0, desc: "right edge" },
535 { nx: -1, ny: 0, desc: "left edge" },
536 { nx: 0, ny: 1, desc: "bottom edge" },
537 { nx: 0, ny: -1, desc: "top edge" },
538 { nx: 0.5, ny: 0.5, desc: "bottom-right quadrant" },
539 { nx: -0.5, ny: -0.5, desc: "top-left quadrant" },
540 ])("should create binding with edge anchor at $desc", ({ nx, ny }) => {
541 const anchor = { kind: "edge" as const, nx, ny };
542 const binding = BindingRecord.create("arrow1", "shape1", "start", anchor);
543
544 expect(binding.anchor).toEqual({ kind: "edge", nx, ny });
545 });
546 });
547});
548
549describe("Document", () => {
550 describe("create", () => {
551 it("should create an empty document", () => {
552 const doc = Document.create();
553
554 expect(doc.pages).toEqual({});
555 expect(doc.shapes).toEqual({});
556 expect(doc.bindings).toEqual({});
557 });
558 });
559
560 describe("clone", () => {
561 it("should clone an empty document", () => {
562 const doc = Document.create();
563 const cloned = Document.clone(doc);
564
565 expect(cloned).toEqual(doc);
566 expect(cloned).not.toBe(doc);
567 });
568
569 it("should deep clone document with pages and shapes", () => {
570 const doc = Document.create();
571 const page = PageRecord.create("Page 1", "page1");
572 const shape = ShapeRecord.createRect(
573 "page1",
574 0,
575 0,
576 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
577 "shape1",
578 );
579
580 page.shapeIds = ["shape1"];
581 doc.pages = { page1: page };
582 doc.shapes = { shape1: shape };
583
584 const cloned = Document.clone(doc);
585
586 expect(cloned).toEqual(doc);
587 expect(cloned.pages).not.toBe(doc.pages);
588 expect(cloned.shapes).not.toBe(doc.shapes);
589 expect(cloned.pages.page1).not.toBe(doc.pages.page1);
590 expect(cloned.shapes.shape1).not.toBe(doc.shapes.shape1);
591 });
592
593 it("should deep clone bindings", () => {
594 const doc = Document.create();
595 const binding = BindingRecord.create("arrow1", "shape1", "start", { kind: "center" }, "binding1");
596 doc.bindings = { binding1: binding };
597
598 const cloned = Document.clone(doc);
599
600 expect(cloned.bindings).not.toBe(doc.bindings);
601 expect(cloned.bindings.binding1).not.toBe(doc.bindings.binding1);
602 expect(cloned.bindings.binding1).toEqual(doc.bindings.binding1);
603 });
604 });
605});
606
607describe("validateDoc", () => {
608 describe("valid documents", () => {
609 it("should validate empty document", () => {
610 const doc = Document.create();
611 const result = validateDoc(doc);
612
613 expect(result.ok).toBe(true);
614 });
615
616 it("should validate document with page and shape", () => {
617 const doc = Document.create();
618 const page = PageRecord.create("Page 1", "page1");
619 const shape = ShapeRecord.createRect(
620 "page1",
621 0,
622 0,
623 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
624 "shape1",
625 );
626
627 page.shapeIds = ["shape1"];
628 doc.pages = { page1: page };
629 doc.shapes = { shape1: shape };
630
631 const result = validateDoc(doc);
632
633 expect(result.ok).toBe(true);
634 });
635
636 it("should validate document with multiple shapes", () => {
637 const doc = Document.create();
638 const page = PageRecord.create("Page 1", "page1");
639 const shape1 = ShapeRecord.createRect(
640 "page1",
641 0,
642 0,
643 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
644 "shape1",
645 );
646 const shape2 = ShapeRecord.createEllipse(
647 "page1",
648 50,
649 50,
650 { w: 75, h: 75, fill: "#000", stroke: "#fff" },
651 "shape2",
652 );
653
654 page.shapeIds = ["shape1", "shape2"];
655 doc.pages = { page1: page };
656 doc.shapes = { shape1, shape2 };
657
658 const result = validateDoc(doc);
659
660 expect(result.ok).toBe(true);
661 });
662
663 it("should validate document with binding", () => {
664 const doc = Document.create();
665 const page = PageRecord.create("Page 1", "page1");
666 const arrow = ShapeRecord.createArrow("page1", 0, 0, {
667 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
668 start: { kind: "free" },
669 end: { kind: "free" },
670 style: { stroke: "#000", width: 2 },
671 }, "arrow1");
672 const rect = ShapeRecord.createRect(
673 "page1",
674 100,
675 0,
676 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
677 "rect1",
678 );
679 const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "center" }, "binding1");
680
681 page.shapeIds = ["arrow1", "rect1"];
682 doc.pages = { page1: page };
683 doc.shapes = { arrow1: arrow, rect1: rect };
684 doc.bindings = { binding1: binding };
685
686 const result = validateDoc(doc);
687
688 expect(result.ok).toBe(true);
689 });
690 });
691
692 describe("invalid documents", () => {
693 it("should reject document with shapes but no pages", () => {
694 const doc = Document.create();
695 const shape = ShapeRecord.createRect(
696 "page1",
697 0,
698 0,
699 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
700 "shape1",
701 );
702 doc.shapes = { shape1: shape };
703
704 const result = validateDoc(doc);
705
706 expect(result.ok).toBe(false);
707 if (!result.ok) {
708 expect(result.errors).toContain("Document has shapes but no pages");
709 }
710 });
711
712 it("should reject shape with mismatched ID", () => {
713 const doc = Document.create();
714 const page = PageRecord.create("Page 1", "page1");
715 const shape = ShapeRecord.createRect(
716 "page1",
717 0,
718 0,
719 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
720 "shape1",
721 );
722
723 page.shapeIds = ["shape1"];
724 doc.pages = { page1: page };
725 doc.shapes = { wrongId: shape };
726
727 const result = validateDoc(doc);
728
729 expect(result.ok).toBe(false);
730 if (!result.ok) {
731 expect(result.errors).toContain("Shape key 'wrongId' does not match shape.id 'shape1'");
732 }
733 });
734
735 it("should reject shape referencing non-existent page", () => {
736 const doc = Document.create();
737 const shape = ShapeRecord.createRect("nonexistent", 0, 0, {
738 w: 100,
739 h: 50,
740 fill: "#fff",
741 stroke: "#000",
742 radius: 0,
743 }, "shape1");
744
745 doc.shapes = { shape1: shape };
746
747 const result = validateDoc(doc);
748
749 expect(result.ok).toBe(false);
750 if (!result.ok) {
751 expect(result.errors).toContain("Shape 'shape1' references non-existent page 'nonexistent'");
752 }
753 });
754
755 it("should reject shape not listed in page shapeIds", () => {
756 const doc = Document.create();
757 const page = PageRecord.create("Page 1", "page1");
758 const shape = ShapeRecord.createRect(
759 "page1",
760 0,
761 0,
762 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
763 "shape1",
764 );
765
766 doc.pages = { page1: page };
767 doc.shapes = { shape1: shape };
768
769 const result = validateDoc(doc);
770
771 expect(result.ok).toBe(false);
772 if (!result.ok) {
773 expect(result.errors).toContain("Shape 'shape1' not listed in page 'page1' shapeIds");
774 }
775 });
776
777 it("should reject page referencing non-existent shape", () => {
778 const doc = Document.create();
779 const page = PageRecord.create("Page 1", "page1");
780
781 page.shapeIds = ["nonexistent"];
782 doc.pages = { page1: page };
783
784 const result = validateDoc(doc);
785
786 expect(result.ok).toBe(false);
787 if (!result.ok) {
788 expect(result.errors).toContain("Page 'page1' references non-existent shape 'nonexistent'");
789 }
790 });
791
792 it("should reject page with duplicate shape IDs", () => {
793 const doc = Document.create();
794 const page = PageRecord.create("Page 1", "page1");
795 const shape = ShapeRecord.createRect(
796 "page1",
797 0,
798 0,
799 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
800 "shape1",
801 );
802
803 page.shapeIds = ["shape1", "shape1"];
804 doc.pages = { page1: page };
805 doc.shapes = { shape1: shape };
806
807 const result = validateDoc(doc);
808
809 expect(result.ok).toBe(false);
810 if (!result.ok) {
811 expect(result.errors).toContain("Page 'page1' has duplicate shape IDs");
812 }
813 });
814
815 it("should reject binding to non-existent fromShape", () => {
816 const doc = Document.create();
817 const page = PageRecord.create("Page 1", "page1");
818 const rect = ShapeRecord.createRect(
819 "page1",
820 0,
821 0,
822 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
823 "rect1",
824 );
825 const binding = BindingRecord.create("nonexistent", "rect1", "end", { kind: "center" }, "binding1");
826
827 page.shapeIds = ["rect1"];
828 doc.pages = { page1: page };
829 doc.shapes = { rect1: rect };
830 doc.bindings = { binding1: binding };
831
832 const result = validateDoc(doc);
833
834 expect(result.ok).toBe(false);
835 if (!result.ok) {
836 expect(result.errors).toContain("Binding 'binding1' references non-existent fromShape 'nonexistent'");
837 }
838 });
839
840 it("should reject binding to non-existent toShape", () => {
841 const doc = Document.create();
842 const page = PageRecord.create("Page 1", "page1");
843 const arrow = ShapeRecord.createArrow("page1", 0, 0, {
844 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
845 start: { kind: "free" },
846 end: { kind: "free" },
847 style: { stroke: "#000", width: 2 },
848 }, "arrow1");
849 const binding = BindingRecord.create("arrow1", "nonexistent", "end", { kind: "center" }, "binding1");
850
851 page.shapeIds = ["arrow1"];
852 doc.pages = { page1: page };
853 doc.shapes = { arrow1: arrow };
854 doc.bindings = { binding1: binding };
855
856 const result = validateDoc(doc);
857
858 expect(result.ok).toBe(false);
859 if (!result.ok) {
860 expect(result.errors).toContain("Binding 'binding1' references non-existent toShape 'nonexistent'");
861 }
862 });
863
864 it("should reject binding from non-arrow shape", () => {
865 const doc = Document.create();
866 const page = PageRecord.create("Page 1", "page1");
867 const rect1 = ShapeRecord.createRect(
868 "page1",
869 0,
870 0,
871 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
872 "rect1",
873 );
874 const rect2 = ShapeRecord.createRect(
875 "page1",
876 100,
877 0,
878 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
879 "rect2",
880 );
881 const binding = BindingRecord.create("rect1", "rect2", "start", { kind: "center" }, "binding1");
882
883 page.shapeIds = ["rect1", "rect2"];
884 doc.pages = { page1: page };
885 doc.shapes = { rect1, rect2 };
886 doc.bindings = { binding1: binding };
887
888 const result = validateDoc(doc);
889
890 expect(result.ok).toBe(false);
891 if (!result.ok) {
892 expect(result.errors).toContain("Binding 'binding1' fromShape 'rect1' is not an arrow");
893 }
894 });
895
896 it("should reject rect with negative width", () => {
897 const doc = Document.create();
898 const page = PageRecord.create("Page 1", "page1");
899 const shape = ShapeRecord.createRect(
900 "page1",
901 0,
902 0,
903 { w: -100, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
904 "shape1",
905 );
906
907 page.shapeIds = ["shape1"];
908 doc.pages = { page1: page };
909 doc.shapes = { shape1: shape };
910
911 const result = validateDoc(doc);
912
913 expect(result.ok).toBe(false);
914 if (!result.ok) {
915 expect(result.errors).toContain("Rect shape 'shape1' has negative width");
916 }
917 });
918
919 it("should reject rect with negative height", () => {
920 const doc = Document.create();
921 const page = PageRecord.create("Page 1", "page1");
922 const shape = ShapeRecord.createRect(
923 "page1",
924 0,
925 0,
926 { w: 100, h: -50, fill: "#fff", stroke: "#000", radius: 0 },
927 "shape1",
928 );
929
930 page.shapeIds = ["shape1"];
931 doc.pages = { page1: page };
932 doc.shapes = { shape1: shape };
933
934 const result = validateDoc(doc);
935
936 expect(result.ok).toBe(false);
937 if (!result.ok) {
938 expect(result.errors).toContain("Rect shape 'shape1' has negative height");
939 }
940 });
941
942 it("should reject rect with negative radius", () => {
943 const doc = Document.create();
944 const page = PageRecord.create("Page 1", "page1");
945 const shape = ShapeRecord.createRect(
946 "page1",
947 0,
948 0,
949 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: -5 },
950 "shape1",
951 );
952
953 page.shapeIds = ["shape1"];
954 doc.pages = { page1: page };
955 doc.shapes = { shape1: shape };
956
957 const result = validateDoc(doc);
958
959 expect(result.ok).toBe(false);
960 if (!result.ok) {
961 expect(result.errors).toContain("Rect shape 'shape1' has negative radius");
962 }
963 });
964
965 it("should reject ellipse with negative dimensions", () => {
966 const doc = Document.create();
967 const page = PageRecord.create("Page 1", "page1");
968 const shape = ShapeRecord.createEllipse(
969 "page1",
970 0,
971 0,
972 { w: -100, h: 50, fill: "#fff", stroke: "#000" },
973 "shape1",
974 );
975
976 page.shapeIds = ["shape1"];
977 doc.pages = { page1: page };
978 doc.shapes = { shape1: shape };
979
980 const result = validateDoc(doc);
981
982 expect(result.ok).toBe(false);
983 if (!result.ok) {
984 expect(result.errors).toContain("Ellipse shape 'shape1' has negative width");
985 }
986 });
987
988 it("should reject line with negative width", () => {
989 const doc = Document.create();
990 const page = PageRecord.create("Page 1", "page1");
991 const shape = ShapeRecord.createLine("page1", 0, 0, {
992 a: { x: 0, y: 0 },
993 b: { x: 100, y: 0 },
994 stroke: "#000",
995 width: -2,
996 }, "shape1");
997
998 page.shapeIds = ["shape1"];
999 doc.pages = { page1: page };
1000 doc.shapes = { shape1: shape };
1001
1002 const result = validateDoc(doc);
1003
1004 expect(result.ok).toBe(false);
1005 if (!result.ok) {
1006 expect(result.errors).toContain("Line shape 'shape1' has negative width");
1007 }
1008 });
1009
1010 it("should reject text with invalid fontSize", () => {
1011 const doc = Document.create();
1012 const page = PageRecord.create("Page 1", "page1");
1013 const shape = ShapeRecord.createText("page1", 0, 0, {
1014 text: "Test",
1015 fontSize: 0,
1016 fontFamily: "Arial",
1017 color: "#000",
1018 }, "shape1");
1019
1020 page.shapeIds = ["shape1"];
1021 doc.pages = { page1: page };
1022 doc.shapes = { shape1: shape };
1023
1024 const result = validateDoc(doc);
1025
1026 expect(result.ok).toBe(false);
1027 if (!result.ok) {
1028 expect(result.errors).toContain("Text shape 'shape1' has invalid fontSize");
1029 }
1030 });
1031
1032 it("should reject text with negative width", () => {
1033 const doc = Document.create();
1034 const page = PageRecord.create("Page 1", "page1");
1035 const shape = ShapeRecord.createText("page1", 0, 0, {
1036 text: "Test",
1037 fontSize: 12,
1038 fontFamily: "Arial",
1039 color: "#000",
1040 w: -100,
1041 }, "shape1");
1042
1043 page.shapeIds = ["shape1"];
1044 doc.pages = { page1: page };
1045 doc.shapes = { shape1: shape };
1046
1047 const result = validateDoc(doc);
1048
1049 expect(result.ok).toBe(false);
1050 if (!result.ok) {
1051 expect(result.errors).toContain("Text shape 'shape1' has negative width");
1052 }
1053 });
1054
1055 it("should collect multiple errors", () => {
1056 const doc = Document.create();
1057 const page = PageRecord.create("Page 1", "page1");
1058 const shape1 = ShapeRecord.createRect(
1059 "page1",
1060 0,
1061 0,
1062 { w: -100, h: -50, fill: "#fff", stroke: "#000", radius: 0 },
1063 "shape1",
1064 );
1065 const shape2 = ShapeRecord.createRect("nonexistent", 0, 0, {
1066 w: 100,
1067 h: 50,
1068 fill: "#fff",
1069 stroke: "#000",
1070 radius: 0,
1071 }, "shape2");
1072
1073 page.shapeIds = ["shape1"];
1074 doc.pages = { page1: page };
1075 doc.shapes = { shape1, shape2 };
1076
1077 const result = validateDoc(doc);
1078
1079 expect(result.ok).toBe(false);
1080 if (!result.ok) {
1081 expect(result.errors.length).toBeGreaterThan(1);
1082 }
1083 });
1084
1085 it("should reject arrow with missing required fields", () => {
1086 const doc = Document.create();
1087 const page = PageRecord.create("Page 1", "page1");
1088 const shape = ShapeRecord.createArrow("page1", 0, 0, {} as any, "arrow1");
1089
1090 page.shapeIds = ["arrow1"];
1091 doc.pages = { page1: page };
1092 doc.shapes = { arrow1: shape };
1093
1094 const result = validateDoc(doc);
1095
1096 expect(result.ok).toBe(false);
1097 // Arrow is invalid because it has no points or style
1098 });
1099
1100 it("should reject arrow with too few points in modern format", () => {
1101 const doc = Document.create();
1102 const page = PageRecord.create("Page 1", "page1");
1103 const shape = ShapeRecord.createArrow("page1", 0, 0, {
1104 points: [{ x: 0, y: 0 }],
1105 start: { kind: "free" },
1106 end: { kind: "free" },
1107 style: { stroke: "#000", width: 2 },
1108 }, "arrow1");
1109
1110 page.shapeIds = ["arrow1"];
1111 doc.pages = { page1: page };
1112 doc.shapes = { arrow1: shape };
1113
1114 const result = validateDoc(doc);
1115
1116 expect(result.ok).toBe(false);
1117 if (!result.ok) {
1118 expect(result.errors).toContain("Arrow shape 'arrow1' points array must have at least 2 points");
1119 }
1120 });
1121
1122 it("should reject arrow with negative width in modern format", () => {
1123 const doc = Document.create();
1124 const page = PageRecord.create("Page 1", "page1");
1125 const shape = ShapeRecord.createArrow("page1", 0, 0, {
1126 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1127 start: { kind: "free" },
1128 end: { kind: "free" },
1129 style: { stroke: "#000", width: -2 },
1130 }, "arrow1");
1131
1132 page.shapeIds = ["arrow1"];
1133 doc.pages = { page1: page };
1134 doc.shapes = { arrow1: shape };
1135
1136 const result = validateDoc(doc);
1137
1138 expect(result.ok).toBe(false);
1139 if (!result.ok) {
1140 expect(result.errors).toContain("Arrow shape 'arrow1' has negative width in style");
1141 }
1142 });
1143
1144 it("should reject arrow with negative cornerRadius", () => {
1145 const doc = Document.create();
1146 const page = PageRecord.create("Page 1", "page1");
1147 const shape = ShapeRecord.createArrow("page1", 0, 0, {
1148 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1149 start: { kind: "free" },
1150 end: { kind: "free" },
1151 style: { stroke: "#000", width: 2 },
1152 routing: { kind: "orthogonal", cornerRadius: -5 },
1153 }, "arrow1");
1154
1155 page.shapeIds = ["arrow1"];
1156 doc.pages = { page1: page };
1157 doc.shapes = { arrow1: shape };
1158
1159 const result = validateDoc(doc);
1160
1161 expect(result.ok).toBe(false);
1162 if (!result.ok) {
1163 expect(result.errors).toContain("Arrow shape 'arrow1' has negative cornerRadius");
1164 }
1165 });
1166
1167 it("should reject arrow with invalid label alignment", () => {
1168 const doc = Document.create();
1169 const page = PageRecord.create("Page 1", "page1");
1170 const shape = ShapeRecord.createArrow("page1", 0, 0, {
1171 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1172 start: { kind: "free" },
1173 end: { kind: "free" },
1174 style: { stroke: "#000", width: 2 },
1175 label: { text: "Test", align: "invalid" as any, offset: 0 },
1176 }, "arrow1");
1177
1178 page.shapeIds = ["arrow1"];
1179 doc.pages = { page1: page };
1180 doc.shapes = { arrow1: shape };
1181
1182 const result = validateDoc(doc);
1183
1184 expect(result.ok).toBe(false);
1185 if (!result.ok) {
1186 expect(result.errors).toContain("Arrow shape 'arrow1' has invalid label alignment");
1187 }
1188 });
1189
1190 it("should reject binding with edge anchor nx out of range", () => {
1191 const doc = Document.create();
1192 const page = PageRecord.create("Page 1", "page1");
1193 const arrow = ShapeRecord.createArrow("page1", 0, 0, {
1194 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1195 start: { kind: "free" },
1196 end: { kind: "free" },
1197 style: { stroke: "#000", width: 2 },
1198 }, "arrow1");
1199 const rect = ShapeRecord.createRect(
1200 "page1",
1201 100,
1202 0,
1203 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
1204 "rect1",
1205 );
1206 const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "edge", nx: 1.5, ny: 0 }, "binding1");
1207
1208 page.shapeIds = ["arrow1", "rect1"];
1209 doc.pages = { page1: page };
1210 doc.shapes = { arrow1: arrow, rect1: rect };
1211 doc.bindings = { binding1: binding };
1212
1213 const result = validateDoc(doc);
1214
1215 expect(result.ok).toBe(false);
1216 if (!result.ok) {
1217 expect(result.errors).toContain("Binding 'binding1' has invalid nx '1.5' (must be in [-1, 1])");
1218 }
1219 });
1220
1221 it("should reject binding with edge anchor ny out of range", () => {
1222 const doc = Document.create();
1223 const page = PageRecord.create("Page 1", "page1");
1224 const arrow = ShapeRecord.createArrow("page1", 0, 0, {
1225 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1226 start: { kind: "free" },
1227 end: { kind: "free" },
1228 style: { stroke: "#000", width: 2 },
1229 }, "arrow1");
1230 const rect = ShapeRecord.createRect(
1231 "page1",
1232 100,
1233 0,
1234 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
1235 "rect1",
1236 );
1237 const binding = BindingRecord.create("arrow1", "rect1", "start", { kind: "edge", nx: 0, ny: -2 }, "binding1");
1238
1239 page.shapeIds = ["arrow1", "rect1"];
1240 doc.pages = { page1: page };
1241 doc.shapes = { arrow1: arrow, rect1: rect };
1242 doc.bindings = { binding1: binding };
1243
1244 const result = validateDoc(doc);
1245
1246 expect(result.ok).toBe(false);
1247 if (!result.ok) {
1248 expect(result.errors).toContain("Binding 'binding1' has invalid ny '-2' (must be in [-1, 1])");
1249 }
1250 });
1251
1252 it("should accept valid modern arrow format", () => {
1253 const doc = Document.create();
1254 const page = PageRecord.create("Page 1", "page1");
1255 const arrow = ShapeRecord.createArrow("page1", 0, 0, {
1256 points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }],
1257 start: { kind: "free" },
1258 end: { kind: "free" },
1259 style: { stroke: "#000", width: 2, headStart: false, headEnd: true, dash: [5, 3] },
1260 routing: { kind: "orthogonal", cornerRadius: 5 },
1261 label: { text: "Connection", align: "center", offset: 0 },
1262 }, "arrow1");
1263
1264 page.shapeIds = ["arrow1"];
1265 doc.pages = { page1: page };
1266 doc.shapes = { arrow1: arrow };
1267
1268 const result = validateDoc(doc);
1269
1270 expect(result.ok).toBe(true);
1271 });
1272
1273 it("should accept binding with valid edge anchor", () => {
1274 const doc = Document.create();
1275 const page = PageRecord.create("Page 1", "page1");
1276 const arrow = ShapeRecord.createArrow("page1", 0, 0, {
1277 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1278 start: { kind: "free" },
1279 end: { kind: "bound", bindingId: "binding1" },
1280 style: { stroke: "#000", width: 2 },
1281 }, "arrow1");
1282 const rect = ShapeRecord.createRect(
1283 "page1",
1284 100,
1285 0,
1286 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
1287 "rect1",
1288 );
1289 const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "edge", nx: 0.5, ny: -0.5 }, "binding1");
1290
1291 page.shapeIds = ["arrow1", "rect1"];
1292 doc.pages = { page1: page };
1293 doc.shapes = { arrow1: arrow, rect1: rect };
1294 doc.bindings = { binding1: binding };
1295
1296 const result = validateDoc(doc);
1297
1298 expect(result.ok).toBe(true);
1299 });
1300 });
1301
1302 describe("edge cases", () => {
1303 it("should accept zero-sized shapes", () => {
1304 const doc = Document.create();
1305 const page = PageRecord.create("Page 1", "page1");
1306 const shape = ShapeRecord.createRect(
1307 "page1",
1308 0,
1309 0,
1310 { w: 0, h: 0, fill: "#fff", stroke: "#000", radius: 0 },
1311 "shape1",
1312 );
1313
1314 page.shapeIds = ["shape1"];
1315 doc.pages = { page1: page };
1316 doc.shapes = { shape1: shape };
1317
1318 const result = validateDoc(doc);
1319
1320 expect(result.ok).toBe(true);
1321 });
1322
1323 it("should accept text with undefined width", () => {
1324 const doc = Document.create();
1325 const page = PageRecord.create("Page 1", "page1");
1326 const shape = ShapeRecord.createText("page1", 0, 0, {
1327 text: "Test",
1328 fontSize: 12,
1329 fontFamily: "Arial",
1330 color: "#000",
1331 }, "shape1");
1332
1333 page.shapeIds = ["shape1"];
1334 doc.pages = { page1: page };
1335 doc.shapes = { shape1: shape };
1336
1337 const result = validateDoc(doc);
1338
1339 expect(result.ok).toBe(true);
1340 });
1341
1342 it("should accept empty page name", () => {
1343 const doc = Document.create();
1344 const page = PageRecord.create("", "page1");
1345 doc.pages = { page1: page };
1346
1347 const result = validateDoc(doc);
1348
1349 expect(result.ok).toBe(true);
1350 });
1351 });
1352});
1353
1354describe("JSON serialization", () => {
1355 it("should round-trip empty document", () => {
1356 const doc = Document.create();
1357 const json = JSON.stringify(doc);
1358 const parsed = JSON.parse(json);
1359
1360 expect(parsed).toEqual(doc);
1361 expect(validateDoc(parsed).ok).toBe(true);
1362 });
1363
1364 it("should round-trip document with page and shape", () => {
1365 const doc = Document.create();
1366 const page = PageRecord.create("Page 1", "page1");
1367 const shape = ShapeRecord.createRect(
1368 "page1",
1369 10,
1370 20,
1371 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 },
1372 "shape1",
1373 );
1374
1375 page.shapeIds = ["shape1"];
1376 doc.pages = { page1: page };
1377 doc.shapes = { shape1: shape };
1378
1379 const json = JSON.stringify(doc);
1380 const parsed = JSON.parse(json);
1381
1382 expect(parsed).toEqual(doc);
1383 expect(validateDoc(parsed).ok).toBe(true);
1384 });
1385
1386 it("should round-trip document with all shape types", () => {
1387 const doc = Document.create();
1388 const page = PageRecord.create("Page 1", "page1");
1389
1390 const rect = ShapeRecord.createRect(
1391 "page1",
1392 0,
1393 0,
1394 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 },
1395 "shape1",
1396 );
1397 const ellipse = ShapeRecord.createEllipse(
1398 "page1",
1399 100,
1400 100,
1401 { w: 75, h: 75, fill: "#f00", stroke: "#000" },
1402 "shape2",
1403 );
1404 const line = ShapeRecord.createLine("page1", 200, 200, {
1405 a: { x: 0, y: 0 },
1406 b: { x: 100, y: 50 },
1407 stroke: "#000",
1408 width: 2,
1409 }, "shape3");
1410 const arrow = ShapeRecord.createArrow("page1", 300, 300, {
1411 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1412 start: { kind: "free" },
1413 end: { kind: "free" },
1414 style: { stroke: "#000", width: 2 },
1415 }, "shape4");
1416 const text = ShapeRecord.createText("page1", 400, 400, {
1417 text: "Hello World",
1418 fontSize: 16,
1419 fontFamily: "Arial",
1420 color: "#000",
1421 w: 200,
1422 }, "shape5");
1423
1424 page.shapeIds = ["shape1", "shape2", "shape3", "shape4", "shape5"];
1425 doc.pages = { page1: page };
1426 doc.shapes = { shape1: rect, shape2: ellipse, shape3: line, shape4: arrow, shape5: text };
1427
1428 const json = JSON.stringify(doc);
1429 const parsed = JSON.parse(json);
1430
1431 expect(parsed).toEqual(doc);
1432 expect(validateDoc(parsed).ok).toBe(true);
1433 });
1434
1435 it("should round-trip document with bindings", () => {
1436 const doc = Document.create();
1437 const page = PageRecord.create("Page 1", "page1");
1438 const arrow = ShapeRecord.createArrow("page1", 0, 0, {
1439 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1440 start: { kind: "free" },
1441 end: { kind: "free" },
1442 style: { stroke: "#000", width: 2 },
1443 }, "arrow1");
1444 const rect = ShapeRecord.createRect(
1445 "page1",
1446 100,
1447 0,
1448 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
1449 "rect1",
1450 );
1451 const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "center" }, "binding1");
1452
1453 page.shapeIds = ["arrow1", "rect1"];
1454 doc.pages = { page1: page };
1455 doc.shapes = { arrow1: arrow, rect1: rect };
1456 doc.bindings = { binding1: binding };
1457
1458 const json = JSON.stringify(doc);
1459 const parsed = JSON.parse(json);
1460
1461 expect(parsed).toEqual(doc);
1462 expect(validateDoc(parsed).ok).toBe(true);
1463 });
1464
1465 it("should round-trip complex document", () => {
1466 const doc = Document.create();
1467 const page1 = PageRecord.create("Page 1", "page1");
1468 const page2 = PageRecord.create("Page 2", "page2");
1469
1470 const shape1 = ShapeRecord.createRect(
1471 "page1",
1472 0,
1473 0,
1474 { w: 100, h: 50, fill: "#fff", stroke: "#000", radius: 5 },
1475 "shape1",
1476 );
1477 const shape2 = ShapeRecord.createEllipse(
1478 "page1",
1479 100,
1480 100,
1481 { w: 75, h: 75, fill: "#f00", stroke: "#000" },
1482 "shape2",
1483 );
1484 const shape3 = ShapeRecord.createArrow("page2", 0, 0, {
1485 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1486 start: { kind: "free" },
1487 end: { kind: "free" },
1488 style: { stroke: "#000", width: 2 },
1489 }, "shape3");
1490 const shape4 = ShapeRecord.createRect(
1491 "page2",
1492 100,
1493 0,
1494 { w: 50, h: 50, fill: "#0f0", stroke: "#000", radius: 0 },
1495 "shape4",
1496 );
1497
1498 const binding = BindingRecord.create("shape3", "shape4", "end", { kind: "center" }, "binding1");
1499
1500 page1.shapeIds = ["shape1", "shape2"];
1501 page2.shapeIds = ["shape3", "shape4"];
1502
1503 doc.pages = { page1, page2 };
1504 doc.shapes = { shape1, shape2, shape3, shape4 };
1505 doc.bindings = { binding1: binding };
1506
1507 const json = JSON.stringify(doc);
1508 const parsed = JSON.parse(json);
1509
1510 expect(parsed).toEqual(doc);
1511 expect(validateDoc(parsed).ok).toBe(true);
1512 });
1513
1514 it("should round-trip arrow with modern format", () => {
1515 const doc = Document.create();
1516 const page = PageRecord.create("Page 1", "page1");
1517 const arrow = ShapeRecord.createArrow("page1", 0, 0, {
1518 points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }],
1519 start: { kind: "free" },
1520 end: { kind: "free" },
1521 style: { stroke: "#ff0000", width: 3, headStart: true, headEnd: true, dash: [5, 3] },
1522 routing: { kind: "orthogonal", cornerRadius: 5 },
1523 label: { text: "Connection", align: "center", offset: 0 },
1524 }, "arrow1");
1525
1526 page.shapeIds = ["arrow1"];
1527 doc.pages = { page1: page };
1528 doc.shapes = { arrow1: arrow };
1529
1530 const json = JSON.stringify(doc);
1531 const parsed = JSON.parse(json);
1532
1533 expect(parsed).toEqual(doc);
1534 expect(validateDoc(parsed).ok).toBe(true);
1535 });
1536
1537 it("should round-trip arrow with bound endpoints", () => {
1538 const doc = Document.create();
1539 const page = PageRecord.create("Page 1", "page1");
1540 const arrow = ShapeRecord.createArrow("page1", 0, 0, {
1541 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1542 start: { kind: "bound", bindingId: "binding1" },
1543 end: { kind: "bound", bindingId: "binding2" },
1544 style: { stroke: "#000", width: 2 },
1545 }, "arrow1");
1546 const rect1 = ShapeRecord.createRect(
1547 "page1",
1548 -50,
1549 -25,
1550 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
1551 "rect1",
1552 );
1553 const rect2 = ShapeRecord.createRect(
1554 "page1",
1555 100,
1556 -25,
1557 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
1558 "rect2",
1559 );
1560 const binding1 = BindingRecord.create("arrow1", "rect1", "start", { kind: "edge", nx: 1, ny: 0 }, "binding1");
1561 const binding2 = BindingRecord.create("arrow1", "rect2", "end", { kind: "edge", nx: -1, ny: 0 }, "binding2");
1562
1563 page.shapeIds = ["arrow1", "rect1", "rect2"];
1564 doc.pages = { page1: page };
1565 doc.shapes = { arrow1: arrow, rect1: rect1, rect2: rect2 };
1566 doc.bindings = { binding1, binding2 };
1567
1568 const json = JSON.stringify(doc);
1569 const parsed = JSON.parse(json);
1570
1571 expect(parsed).toEqual(doc);
1572 expect(validateDoc(parsed).ok).toBe(true);
1573 });
1574
1575 it("should round-trip binding with edge anchor", () => {
1576 const doc = Document.create();
1577 const page = PageRecord.create("Page 1", "page1");
1578 const arrow = ShapeRecord.createArrow("page1", 0, 0, {
1579 points: [{ x: 0, y: 0 }, { x: 100, y: 0 }],
1580 start: { kind: "free" },
1581 end: { kind: "free" },
1582 style: { stroke: "#000", width: 2 },
1583 }, "arrow1");
1584 const rect = ShapeRecord.createRect(
1585 "page1",
1586 100,
1587 0,
1588 { w: 50, h: 50, fill: "#fff", stroke: "#000", radius: 0 },
1589 "rect1",
1590 );
1591 const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "edge", nx: -0.5, ny: 0.5 }, "binding1");
1592
1593 page.shapeIds = ["arrow1", "rect1"];
1594 doc.pages = { page1: page };
1595 doc.shapes = { arrow1: arrow, rect1: rect };
1596 doc.bindings = { binding1: binding };
1597
1598 const json = JSON.stringify(doc);
1599 const parsed = JSON.parse(json);
1600
1601 expect(parsed).toEqual(doc);
1602 expect(validateDoc(parsed).ok).toBe(true);
1603 });
1604});