jazz-tools 0.9.1 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,934 @@
1
+ import { connectedPeers } from "cojson/src/streamUtils.ts";
2
+ import { describe, expect, test } from "vitest";
3
+ import { splitNode } from "../coValues/coRichText.js";
4
+ import {
5
+ Account,
6
+ CoRichText,
7
+ Marks,
8
+ type TextPos,
9
+ type TreeNode,
10
+ WasmCrypto,
11
+ cojsonInternals,
12
+ createJazzContext,
13
+ fixedCredentialsAuth,
14
+ isControlledAccount,
15
+ } from "../index.web.js";
16
+ import { randomSessionProvider } from "../internal.js";
17
+
18
+ const Crypto = await WasmCrypto.create();
19
+
20
+ describe("CoRichText", async () => {
21
+ const me = await Account.create({
22
+ creationProps: { name: "Hermes Puggington" },
23
+ crypto: Crypto,
24
+ });
25
+
26
+ const text = CoRichText.createFromPlainText("hello world", { owner: me });
27
+
28
+ test("Construction", () => {
29
+ expect(text + "").toEqual("hello world");
30
+ });
31
+
32
+ describe("Mutation", () => {
33
+ test("insertion", () => {
34
+ const text = CoRichText.createFromPlainText("hello world", {
35
+ owner: me,
36
+ });
37
+
38
+ text.insertAfter(5, " cruel");
39
+ expect(text + "").toEqual("hello cruel world");
40
+ });
41
+
42
+ test("deletion", () => {
43
+ const text = CoRichText.createFromPlainText("hello world", {
44
+ owner: me,
45
+ });
46
+
47
+ text.deleteRange({ from: 3, to: 8 });
48
+ expect(text + "").toEqual("helrld");
49
+ });
50
+
51
+ describe("inserting marks", () => {
52
+ test("basic mark insertion", () => {
53
+ const text = CoRichText.createFromPlainText("hello world", {
54
+ owner: me,
55
+ });
56
+
57
+ // Add mark covering "hello"
58
+ text.insertMark(0, 4, Marks.Strong, { tag: "strong" });
59
+
60
+ const marks = text.resolveMarks();
61
+ expect(marks).toHaveLength(1);
62
+ expect(marks[0]).toMatchObject({
63
+ startAfter: 0,
64
+ startBefore: 1,
65
+ endAfter: 3,
66
+ endBefore: 5,
67
+ tag: "strong",
68
+ });
69
+ });
70
+
71
+ test("inserting mark with custom owner", () => {
72
+ const text = CoRichText.createFromPlainText("hello world", {
73
+ owner: me,
74
+ });
75
+
76
+ text.insertMark(
77
+ 0,
78
+ 5,
79
+ Marks.Strong,
80
+ { tag: "strong" },
81
+ { markOwner: me },
82
+ );
83
+
84
+ const mark = text.marks![0];
85
+ expect(mark!._owner).toStrictEqual(me);
86
+ });
87
+
88
+ test("inserting multiple non-overlapping marks", () => {
89
+ const text = CoRichText.createFromPlainText("hello world", {
90
+ owner: me,
91
+ });
92
+
93
+ text.insertMark(0, 5, Marks.Strong, { tag: "strong" }); // "hello"
94
+ text.insertMark(6, 11, Marks.Em, { tag: "em" }); // "world"
95
+
96
+ const marks = text.resolveMarks();
97
+ expect(marks).toHaveLength(2);
98
+
99
+ // Verify positions and types
100
+ const [mark1, mark2] = marks;
101
+ expect(mark1!.sourceMark.tag).toBe("strong");
102
+ expect(mark2!.sourceMark.tag).toBe("em");
103
+
104
+ expect(mark1!.startAfter).toBe(0);
105
+ expect(mark1!.endBefore).toBe(6);
106
+ expect(mark2!.startAfter).toBe(5);
107
+ expect(mark2!.endBefore).toBe(11);
108
+ });
109
+
110
+ test("inserting mark with additional properties", () => {
111
+ const text = CoRichText.createFromPlainText("hello world", {
112
+ owner: me,
113
+ });
114
+
115
+ text.insertMark(0, 5, Marks.Link, {
116
+ tag: "link",
117
+ url: "https://example.com",
118
+ });
119
+
120
+ const marks = text.resolveMarks();
121
+ expect(marks).toHaveLength(1);
122
+ expect(marks[0]!.sourceMark).toHaveProperty(
123
+ "url",
124
+ "https://example.com",
125
+ );
126
+ });
127
+
128
+ test("inserting mark at text boundaries", () => {
129
+ const text = CoRichText.createFromPlainText("hello world", {
130
+ owner: me,
131
+ });
132
+
133
+ // Mark entire text
134
+ text.insertMark(0, 11, Marks.Strong, { tag: "strong" });
135
+
136
+ const marks = text.resolveMarks();
137
+ expect(marks).toHaveLength(1);
138
+ expect(marks[0]!.startAfter).toBe(0);
139
+ expect(marks[0]!.endAfter).toBe(10);
140
+ });
141
+
142
+ test("inserting mark outside of text bounds", () => {
143
+ const text = CoRichText.createFromPlainText("hello world", {
144
+ owner: me,
145
+ });
146
+
147
+ text.insertMark(-1, 12, Marks.Strong, { tag: "strong" });
148
+ expect(text.resolveMarks()).toHaveLength(1);
149
+ expect(text.resolveMarks()[0]!.startAfter).toBe(0);
150
+ expect(text.resolveMarks()[0]!.endAfter).toBe(10);
151
+ });
152
+
153
+ test("maintains correct mark ordering with nested marks", () => {
154
+ const text = CoRichText.createFromPlainText("hello world", {
155
+ owner: me,
156
+ });
157
+
158
+ text.insertMark(0, 11, Marks.Strong, { tag: "strong" });
159
+ text.insertMark(2, 8, Marks.Em, { tag: "em" });
160
+ text.insertMark(4, 6, Marks.Link, {
161
+ tag: "link",
162
+ url: "https://example.com",
163
+ });
164
+
165
+ const tree = text.toTree(["strong", "em", "link"]);
166
+ // Verify the nesting structure is correct
167
+ // Strong should contain Em which should contain Link
168
+ expect(tree).toEqual({
169
+ type: "node",
170
+ tag: "root",
171
+ start: 0,
172
+ end: 11,
173
+ children: [
174
+ {
175
+ type: "node",
176
+ tag: "strong",
177
+ start: 0,
178
+ end: 2,
179
+ children: [
180
+ {
181
+ type: "leaf",
182
+ start: 0,
183
+ end: 2,
184
+ },
185
+ ],
186
+ },
187
+ {
188
+ type: "node",
189
+ tag: "strong",
190
+ start: 2,
191
+ end: 4,
192
+ children: [
193
+ {
194
+ type: "node",
195
+ tag: "em",
196
+ start: 2,
197
+ end: 4,
198
+ children: [
199
+ {
200
+ type: "leaf",
201
+ start: 2,
202
+ end: 4,
203
+ },
204
+ ],
205
+ },
206
+ ],
207
+ },
208
+ {
209
+ type: "node",
210
+ tag: "strong",
211
+ start: 4,
212
+ end: 6,
213
+ children: [
214
+ {
215
+ type: "node",
216
+ tag: "em",
217
+ start: 4,
218
+ end: 6,
219
+ children: [
220
+ {
221
+ type: "node",
222
+ tag: "link",
223
+ start: 4,
224
+ end: 6,
225
+ children: [
226
+ {
227
+ type: "leaf",
228
+ start: 4,
229
+ end: 6,
230
+ },
231
+ ],
232
+ },
233
+ ],
234
+ },
235
+ ],
236
+ },
237
+ {
238
+ type: "node",
239
+ tag: "strong",
240
+ start: 6,
241
+ end: 8,
242
+ children: [
243
+ {
244
+ type: "node",
245
+ tag: "em",
246
+ start: 6,
247
+ end: 8,
248
+ children: [
249
+ {
250
+ type: "leaf",
251
+ start: 6,
252
+ end: 8,
253
+ },
254
+ ],
255
+ },
256
+ ],
257
+ },
258
+ {
259
+ type: "node",
260
+ tag: "strong",
261
+ start: 8,
262
+ end: 11,
263
+ children: [
264
+ {
265
+ type: "leaf",
266
+ start: 8,
267
+ end: 11,
268
+ },
269
+ ],
270
+ },
271
+ ],
272
+ });
273
+ });
274
+ });
275
+
276
+ describe("removing marks", () => {
277
+ test("basic mark removal", () => {
278
+ const text = CoRichText.createFromPlainText("hello world", {
279
+ owner: me,
280
+ });
281
+
282
+ // Add a mark
283
+ text.insertMark(0, 5, Marks.Strong, { tag: "strong" });
284
+
285
+ // Verify mark was added
286
+ expect(text.resolveMarks()).toHaveLength(1);
287
+
288
+ // Remove the mark
289
+ text.removeMark(0, 5, Marks.Strong, { tag: "strong" });
290
+
291
+ // Verify mark was removed
292
+ expect(text.resolveMarks()).toHaveLength(0);
293
+ });
294
+
295
+ test("skips marks that aren't in the range", () => {
296
+ const text = CoRichText.createFromPlainText("hello world", {
297
+ owner: me,
298
+ });
299
+
300
+ text.insertMark(0, 2, Marks.Strong, { tag: "strong" });
301
+ text.removeMark(3, 6, Marks.Strong, { tag: "strong" });
302
+ text.insertMark(7, 11, Marks.Strong, { tag: "strong" });
303
+
304
+ expect(text.resolveMarks()).toHaveLength(2);
305
+ });
306
+
307
+ test("removing marks with partial overlap", () => {
308
+ const text = CoRichText.createFromPlainText("hello world", {
309
+ owner: me,
310
+ });
311
+
312
+ // Add marks at different positions
313
+ text.insertMark(0, 6, Marks.Strong, { tag: "strong" }); // "hello "
314
+ text.insertMark(4, 11, Marks.Strong, { tag: "strong" }); // "o world"
315
+
316
+ // Verify initial marks
317
+ expect(text.resolveMarks()).toHaveLength(2);
318
+
319
+ // Remove mark in middle (4-7: "o w") where the marks overlap
320
+ // This should trim both marks to exclude the removed region:
321
+ // - First mark should become "hell" (was "hello ")
322
+ // - Second mark should become "rld" (was "o world")
323
+ text.removeMark(4, 7, Marks.Strong, { tag: "strong" });
324
+
325
+ // Should have two marks remaining - one before and one after the removal
326
+ const remainingMarks = text.resolveMarks();
327
+ expect(remainingMarks).toHaveLength(2);
328
+
329
+ // Verify the remaining marks
330
+ // First mark should be trimmed to "hell"
331
+ expect(remainingMarks[0]!.startAfter).toBe(0);
332
+ expect(remainingMarks[0]!.startBefore).toBe(1);
333
+ expect(remainingMarks[0]!.endAfter).toBe(3);
334
+ expect(remainingMarks[0]!.endBefore).toBe(4);
335
+
336
+ // Second mark should be trimmed to "rld"
337
+ expect(remainingMarks[1]!.startAfter).toBe(7);
338
+ expect(remainingMarks[1]!.startBefore).toBe(8);
339
+ expect(remainingMarks[1]!.endAfter).toBe(10);
340
+ expect(remainingMarks[1]!.endBefore).toBe(11);
341
+
342
+ // Verify the text content is still intact
343
+ expect(text.toString()).toBe("hello world");
344
+ });
345
+
346
+ test("removing marks of specific type", () => {
347
+ const text = CoRichText.createFromPlainText("hello world", {
348
+ owner: me,
349
+ });
350
+
351
+ // Add different types of marks
352
+ text.insertMark(0, 5, Marks.Strong, { tag: "strong" });
353
+ text.insertMark(0, 5, Marks.Em, { tag: "em" });
354
+
355
+ // Verify both marks were added
356
+ expect(text.resolveMarks()).toHaveLength(2);
357
+
358
+ // Remove only Strong marks
359
+ text.removeMark(0, 5, Marks.Strong, { tag: "strong" });
360
+
361
+ // Should have one mark remaining
362
+ const remainingMarks = text.resolveMarks();
363
+ expect(remainingMarks).toHaveLength(1);
364
+ expect(remainingMarks[0]!.sourceMark!.tag).toBe("em");
365
+ });
366
+
367
+ test("removing mark that overlaps end of existing mark", () => {
368
+ const text = CoRichText.createFromPlainText("hello world", {
369
+ owner: me,
370
+ });
371
+
372
+ // Add mark covering "hello world"
373
+ text.insertMark(0, 11, Marks.Strong, { tag: "strong" });
374
+
375
+ // Remove mark covering "world"
376
+ text.removeMark(6, 11, Marks.Strong, { tag: "strong" });
377
+
378
+ // Should have one mark remaining on "hello "
379
+ const remainingMarks = text.resolveMarks();
380
+ expect(remainingMarks).toHaveLength(1);
381
+ expect(remainingMarks[0]!.startAfter).toBe(0);
382
+ expect(remainingMarks[0]!.endAfter).toBe(5);
383
+ });
384
+
385
+ test("removing mark that overlaps start of existing mark", () => {
386
+ const text = CoRichText.createFromPlainText("hello world", {
387
+ owner: me,
388
+ });
389
+
390
+ // Add mark covering "hello world"
391
+ text.insertMark(0, 11, Marks.Strong, { tag: "strong" });
392
+
393
+ // Remove mark covering "hello "
394
+ text.removeMark(0, 5, Marks.Strong, { tag: "strong" });
395
+
396
+ // Should have one mark remaining on "world"
397
+ const remainingMarks = text.resolveMarks();
398
+ expect(remainingMarks).toHaveLength(1);
399
+ expect(remainingMarks[0]!.startAfter).toBe(5);
400
+ expect(remainingMarks[0]!.startBefore).toBe(6);
401
+ expect(remainingMarks[0]!.endAfter).toBe(10);
402
+ expect(remainingMarks[0]!.endBefore).toBe(11);
403
+ });
404
+
405
+ test("removing mark from middle of existing mark", () => {
406
+ const text = CoRichText.createFromPlainText("hello world", {
407
+ owner: me,
408
+ });
409
+
410
+ // Add mark covering "hello world"
411
+ text.insertMark(0, 10, Marks.Strong, { tag: "strong" });
412
+
413
+ // Remove mark covering " wo"
414
+ text.removeMark(5, 8, Marks.Strong, { tag: "strong" });
415
+
416
+ // Should have two marks remaining on "hello" and "rld"
417
+ const remainingMarks = text.resolveMarks();
418
+ expect(remainingMarks).toHaveLength(2);
419
+
420
+ // First mark should cover "hello"
421
+ expect(remainingMarks[0]!.startAfter).toBe(0);
422
+ expect(remainingMarks[0]!.startBefore).toBe(1);
423
+ expect(remainingMarks[0]!.endAfter).toBe(4);
424
+ expect(remainingMarks[0]!.endBefore).toBe(5);
425
+
426
+ // Second mark should cover "rld"
427
+ expect(remainingMarks[1]!.startAfter).toBe(8);
428
+ expect(remainingMarks[1]!.endAfter).toBe(10);
429
+ });
430
+ });
431
+ });
432
+
433
+ describe("Conversion", () => {
434
+ test("to tree", () => {
435
+ const text = CoRichText.createFromPlainText("hello world", {
436
+ owner: me,
437
+ });
438
+
439
+ expect(text.toTree(["strong"])).toEqual({
440
+ type: "node",
441
+ tag: "root",
442
+ start: 0,
443
+ end: 11,
444
+ children: [
445
+ {
446
+ type: "leaf",
447
+ start: 0,
448
+ end: 11,
449
+ },
450
+ ],
451
+ });
452
+ });
453
+
454
+ test("to string", () => {
455
+ const text = CoRichText.createFromPlainText("hello world", {
456
+ owner: me,
457
+ });
458
+
459
+ expect(text.toString()).toEqual("hello world");
460
+ });
461
+
462
+ test("splits nested children correctly", () => {
463
+ // Create text with nested marks
464
+ const text = CoRichText.createFromPlainText("hello world", {
465
+ owner: me,
466
+ });
467
+
468
+ // Add an outer mark spanning the whole text
469
+ text.insertMark(0, 11, Marks.Strong, { tag: "strong" });
470
+
471
+ // Add an inner mark spanning part of the text
472
+ text.insertMark(6, 11, Marks.Em, { tag: "em" });
473
+
474
+ // Split at position 8 (between 'wo' and 'rld')
475
+ const tree = text.toTree(["strong", "em"]);
476
+
477
+ expect(tree).toEqual({
478
+ type: "node",
479
+ tag: "root",
480
+ start: 0,
481
+ end: 11,
482
+ children: [
483
+ {
484
+ type: "node",
485
+ tag: "strong",
486
+ start: 0,
487
+ end: 6,
488
+ children: [
489
+ {
490
+ type: "leaf",
491
+ start: 0,
492
+ end: 6,
493
+ },
494
+ ],
495
+ },
496
+ {
497
+ type: "node",
498
+ tag: "strong",
499
+ start: 6,
500
+ end: 11,
501
+ children: [
502
+ {
503
+ type: "node",
504
+ tag: "em",
505
+ start: 6,
506
+ end: 11,
507
+ children: [
508
+ {
509
+ type: "leaf",
510
+ start: 6,
511
+ end: 11,
512
+ },
513
+ ],
514
+ },
515
+ ],
516
+ },
517
+ ],
518
+ });
519
+
520
+ // Now verify splitting works by checking a specific position
521
+ const [before, after] = splitNode(tree.children[1] as TreeNode, 8);
522
+
523
+ // Verify the structure of the split nodes
524
+ expect(before).toEqual({
525
+ type: "node",
526
+ tag: "strong",
527
+ start: 6,
528
+ end: 8,
529
+ children: [
530
+ {
531
+ type: "node",
532
+ tag: "em",
533
+ start: 6,
534
+ end: 8,
535
+ children: [
536
+ {
537
+ type: "leaf",
538
+ start: 6,
539
+ end: 8,
540
+ },
541
+ ],
542
+ },
543
+ ],
544
+ });
545
+
546
+ expect(after).toEqual({
547
+ type: "node",
548
+ tag: "strong",
549
+ start: 8,
550
+ end: 11,
551
+ children: [
552
+ {
553
+ type: "node",
554
+ tag: "em",
555
+ start: 8,
556
+ end: 11,
557
+ children: [
558
+ {
559
+ type: "leaf",
560
+ start: 8,
561
+ end: 11,
562
+ },
563
+ ],
564
+ },
565
+ ],
566
+ });
567
+ });
568
+ });
569
+
570
+ describe("Resolution", () => {
571
+ const initNodeAndText = async () => {
572
+ const me = await Account.create({
573
+ creationProps: { name: "Hermes Puggington" },
574
+ crypto: Crypto,
575
+ });
576
+
577
+ const text = CoRichText.createFromPlainText("hello world", {
578
+ owner: me,
579
+ });
580
+
581
+ return { me, text };
582
+ };
583
+
584
+ test("Loading and availability", async () => {
585
+ const { me, text } = await initNodeAndText();
586
+
587
+ const [initialAsPeer, secondPeer] = connectedPeers("initial", "second", {
588
+ peer1role: "server",
589
+ peer2role: "client",
590
+ });
591
+ if (!isControlledAccount(me)) {
592
+ throw "me is not a controlled account";
593
+ }
594
+ me._raw.core.node.syncManager.addPeer(secondPeer);
595
+ const { account: meOnSecondPeer } = await createJazzContext({
596
+ auth: fixedCredentialsAuth({
597
+ accountID: me.id,
598
+ secret: me._raw.agentSecret,
599
+ }),
600
+ sessionProvider: randomSessionProvider,
601
+ peersToLoadFrom: [initialAsPeer],
602
+ crypto: Crypto,
603
+ });
604
+
605
+ const loadedText = await CoRichText.load(text.id, meOnSecondPeer, {
606
+ marks: [{}],
607
+ text: [],
608
+ });
609
+
610
+ expect(loadedText).toBeDefined();
611
+ expect(loadedText?.toString()).toEqual("hello world");
612
+
613
+ const loadedText2 = await CoRichText.load(text.id, meOnSecondPeer, {
614
+ marks: [{}],
615
+ text: [],
616
+ });
617
+
618
+ expect(loadedText2).toBeDefined();
619
+ expect(loadedText2?.toString()).toEqual("hello world");
620
+ });
621
+
622
+ test("Subscription & auto-resolution", async () => {
623
+ const { me, text } = await initNodeAndText();
624
+
625
+ const [initialAsPeer, secondPeer] = connectedPeers("initial", "second", {
626
+ peer1role: "server",
627
+ peer2role: "client",
628
+ });
629
+
630
+ if (!isControlledAccount(me)) {
631
+ throw "me is not a controlled account";
632
+ }
633
+ me._raw.core.node.syncManager.addPeer(secondPeer);
634
+ const { account: meOnSecondPeer } = await createJazzContext({
635
+ auth: fixedCredentialsAuth({
636
+ accountID: me.id,
637
+ secret: me._raw.agentSecret,
638
+ }),
639
+ sessionProvider: randomSessionProvider,
640
+ peersToLoadFrom: [initialAsPeer],
641
+ crypto: Crypto,
642
+ });
643
+
644
+ const queue = new cojsonInternals.Channel<CoRichText>();
645
+
646
+ CoRichText.subscribe(
647
+ text.id,
648
+ meOnSecondPeer,
649
+ { marks: [{}], text: [] },
650
+ (subscribedText) => {
651
+ void queue.push(subscribedText);
652
+ },
653
+ );
654
+
655
+ const update1 = (await queue.next()).value;
656
+ expect(update1.toString()).toBe("hello world");
657
+
658
+ text.insertAfter(5, " beautiful");
659
+ const update2 = (await queue.next()).value;
660
+ expect(update2.toString()).toBe("hello beautiful world");
661
+
662
+ text.deleteRange({ from: 5, to: 15 });
663
+ const update3 = (await queue.next()).value;
664
+ expect(update3.toString()).toBe("hello world");
665
+
666
+ text.insertMark(0, 11, Marks.Strong, { tag: "strong" });
667
+ const update4 = (await queue.next()).value;
668
+ expect(update4.toString()).toBe("hello world");
669
+ expect(update4.resolveMarks()).toHaveLength(1);
670
+ expect(update4.resolveMarks()[0]!.tag).toBe("strong");
671
+
672
+ text.removeMark(5, 11, Marks.Strong, { tag: "strong" });
673
+ const update5 = (await queue.next()).value;
674
+ expect(update5.toString()).toBe("hello world");
675
+ expect(update5.resolveMarks()).toHaveLength(1);
676
+ expect(update5.resolveMarks()[0]!.tag).toBe("strong");
677
+ expect(update5.resolveMarks()[0]!.startAfter).toBe(0);
678
+ expect(update5.resolveMarks()[0]!.startBefore).toBe(1);
679
+ expect(update5.resolveMarks()[0]!.endAfter).toBe(4);
680
+ expect(update5.resolveMarks()[0]!.endBefore).toBe(5);
681
+ });
682
+ });
683
+
684
+ // In the sense of the mark resolving in the text, not sync resolution
685
+ describe("Mark resolution", () => {
686
+ test("basic position resolution", () => {
687
+ const text = CoRichText.createFromPlainText("hello world", {
688
+ owner: me,
689
+ });
690
+
691
+ // Create mark directly
692
+ const mark = Marks.Strong.create(
693
+ {
694
+ tag: "strong",
695
+ startAfter: text.posAfter(0) as TextPos,
696
+ startBefore: text.posBefore(1) as TextPos,
697
+ endAfter: text.posAfter(4) as TextPos,
698
+ endBefore: text.posBefore(5) as TextPos,
699
+ },
700
+ { owner: me },
701
+ );
702
+
703
+ // Add mark directly to marks list
704
+ text.marks!.push(mark);
705
+
706
+ const marks = text.resolveMarks();
707
+ expect(marks).toHaveLength(1);
708
+ expect(marks[0]).toMatchObject({
709
+ startAfter: 0,
710
+ startBefore: 1,
711
+ endAfter: 4,
712
+ endBefore: 5,
713
+ tag: "strong",
714
+ });
715
+ });
716
+
717
+ test("handles multiple marks", () => {
718
+ const text = CoRichText.createFromPlainText("hello world", {
719
+ owner: me,
720
+ });
721
+
722
+ // Create marks directly
723
+ const mark1 = Marks.Strong.create(
724
+ {
725
+ tag: "strong",
726
+ startAfter: text.posAfter(0) as TextPos,
727
+ startBefore: text.posBefore(1) as TextPos,
728
+ endAfter: text.posAfter(4) as TextPos,
729
+ endBefore: text.posBefore(5) as TextPos,
730
+ },
731
+ { owner: me },
732
+ );
733
+
734
+ const mark2 = Marks.Em.create(
735
+ {
736
+ tag: "em",
737
+ startAfter: text.posAfter(6) as TextPos,
738
+ startBefore: text.posBefore(7) as TextPos,
739
+ endAfter: text.posAfter(10) as TextPos,
740
+ endBefore: text.posBefore(11) as TextPos,
741
+ },
742
+ { owner: me },
743
+ );
744
+
745
+ // Add marks directly
746
+ text.marks!.push(mark1);
747
+ text.marks!.push(mark2);
748
+
749
+ const marks = text.resolveMarks();
750
+ expect(marks).toHaveLength(2);
751
+
752
+ // First mark
753
+ expect(marks[0]).toMatchObject({
754
+ startAfter: 0,
755
+ startBefore: 1,
756
+ endAfter: 4,
757
+ endBefore: 5,
758
+ tag: "strong",
759
+ });
760
+
761
+ // Second mark
762
+ expect(marks[1]).toMatchObject({
763
+ startAfter: 6,
764
+ startBefore: 7,
765
+ endAfter: 10,
766
+ endBefore: 11,
767
+ tag: "em",
768
+ });
769
+ });
770
+
771
+ test("handles overlapping marks", () => {
772
+ const text = CoRichText.createFromPlainText("hello world", {
773
+ owner: me,
774
+ });
775
+
776
+ // Create overlapping marks directly
777
+ const mark1 = Marks.Strong.create(
778
+ {
779
+ tag: "strong",
780
+ startAfter: text.posAfter(0) as TextPos,
781
+ startBefore: text.posBefore(1) as TextPos,
782
+ endAfter: text.posAfter(4) as TextPos,
783
+ endBefore: text.posBefore(5) as TextPos,
784
+ },
785
+ { owner: me },
786
+ );
787
+
788
+ const mark2 = Marks.Em.create(
789
+ {
790
+ tag: "em",
791
+ startAfter: text.posAfter(3) as TextPos,
792
+ startBefore: text.posBefore(4) as TextPos,
793
+ endAfter: text.posAfter(7) as TextPos,
794
+ endBefore: text.posBefore(8) as TextPos,
795
+ },
796
+ { owner: me },
797
+ );
798
+
799
+ // Add marks directly
800
+ text.marks!.push(mark1);
801
+ text.marks!.push(mark2);
802
+
803
+ const marks = text.resolveMarks();
804
+ expect(marks).toHaveLength(2);
805
+
806
+ // First mark
807
+ expect(marks[0]).toMatchObject({
808
+ startAfter: 0,
809
+ startBefore: 1,
810
+ endAfter: 4,
811
+ endBefore: 5,
812
+ tag: "strong",
813
+ });
814
+
815
+ // Second mark
816
+ expect(marks[1]).toMatchObject({
817
+ startAfter: 3,
818
+ startBefore: 4,
819
+ endAfter: 7,
820
+ endBefore: 8,
821
+ tag: "em",
822
+ });
823
+ });
824
+ });
825
+
826
+ describe("Mark", () => {
827
+ test("basic mark", () => {
828
+ const mark = Marks.Strong.create(
829
+ {
830
+ tag: "strong",
831
+ startAfter: text.posAfter(0) as TextPos,
832
+ startBefore: text.posBefore(1) as TextPos,
833
+ endAfter: text.posAfter(4) as TextPos,
834
+ endBefore: text.posBefore(5) as TextPos,
835
+ },
836
+ { owner: me },
837
+ );
838
+ expect(mark.tag).toEqual("strong");
839
+ });
840
+
841
+ test("validates positions correctly", () => {
842
+ const mark = Marks.Strong.create(
843
+ {
844
+ tag: "strong",
845
+ startAfter: text.posAfter(0) as TextPos,
846
+ startBefore: text.posBefore(1) as TextPos,
847
+ endAfter: text.posAfter(4) as TextPos,
848
+ endBefore: text.posBefore(5) as TextPos,
849
+ },
850
+ { owner: me },
851
+ );
852
+
853
+ const result = mark.validatePositions(
854
+ 11, // text length
855
+ (pos: TextPos) => text.idxAfter(pos),
856
+ (pos: TextPos) => text.idxBefore(pos),
857
+ );
858
+
859
+ expect(result).toEqual({
860
+ startAfter: 0,
861
+ startBefore: 1,
862
+ endAfter: 4,
863
+ endBefore: 5,
864
+ });
865
+ });
866
+
867
+ test("clamps positions to text bounds", () => {
868
+ const mark = Marks.Strong.create(
869
+ {
870
+ tag: "strong",
871
+ startAfter: text.posAfter(-5) as TextPos, // Invalid position
872
+ startBefore: text.posBefore(1) as TextPos,
873
+ endAfter: text.posAfter(4) as TextPos,
874
+ endBefore: text.posBefore(20) as TextPos, // Beyond text length
875
+ },
876
+ { owner: me },
877
+ );
878
+
879
+ const result = mark.validatePositions(
880
+ 11,
881
+ (pos: TextPos) => text.idxAfter(pos),
882
+ (pos: TextPos) => text.idxBefore(pos),
883
+ );
884
+
885
+ expect(result).toMatchObject({
886
+ startAfter: 0, // Clamped to start
887
+ startBefore: 1,
888
+ endAfter: 4,
889
+ endBefore: 11, // Clamped to text length
890
+ });
891
+ });
892
+
893
+ test("different mark types have correct tags", () => {
894
+ const strongMark = Marks.Strong.create(
895
+ {
896
+ tag: "strong",
897
+ startAfter: text.posAfter(0) as TextPos,
898
+ startBefore: text.posBefore(1) as TextPos,
899
+ endAfter: text.posAfter(4) as TextPos,
900
+ endBefore: text.posBefore(5) as TextPos,
901
+ },
902
+ { owner: me },
903
+ );
904
+
905
+ const emMark = Marks.Em.create(
906
+ {
907
+ tag: "em",
908
+ startAfter: text.posAfter(0) as TextPos,
909
+ startBefore: text.posBefore(1) as TextPos,
910
+ endAfter: text.posAfter(4) as TextPos,
911
+ endBefore: text.posBefore(5) as TextPos,
912
+ },
913
+ { owner: me },
914
+ );
915
+
916
+ const linkMark = Marks.Link.create(
917
+ {
918
+ tag: "link",
919
+ url: "https://example.com",
920
+ startAfter: text.posAfter(0) as TextPos,
921
+ startBefore: text.posBefore(1) as TextPos,
922
+ endAfter: text.posAfter(4) as TextPos,
923
+ endBefore: text.posBefore(5) as TextPos,
924
+ },
925
+ { owner: me },
926
+ );
927
+
928
+ expect(strongMark.tag).toBe("strong");
929
+ expect(emMark.tag).toBe("em");
930
+ expect(linkMark.tag).toBe("link");
931
+ expect(linkMark).toHaveProperty("url", "https://example.com");
932
+ });
933
+ });
934
+ });