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