web based infinite canvas
at main 1604 lines 52 kB view raw
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});