diff --git a/07_list.test.ts b/07_list.test.ts index a96e933..2fc975a 100644 --- a/07_list.test.ts +++ b/07_list.test.ts @@ -1,33 +1,164 @@ -import { Loro, LoroList, LoroMovableList, LoroText } from "npm:loro-crdt@0.15.0"; +import { Cursor, Loro } from "npm:loro-crdt@0.15.0"; import { expect } from "npm:expect@29.7.0"; Deno.test("List", () => { - let list = new LoroList(); - list.push(0); - list.push("1"); - const doc = new Loro(); - const map = doc.getMap("root"); - list = map.setContainer("list", list); - expect(doc.toJson()).toStrictEqual({ root: { list: [0, "1"] } }); - list.delete(0, 1); - expect(doc.toJson()).toStrictEqual({ root: { list: ["1"] } }); + const docA = new Loro(); + docA.setPeerId("1"); + const listA = docA.getList("list"); + listA.push(0); + listA.push(1); + listA.push(2); + const bytes: Uint8Array = docA.exportSnapshot(); + + const docB = Loro.fromSnapshot(bytes); + docB.setPeerId("2"); + const listB = docB.getList("list"); + { + // Concurrently docA and docB update element at index 2 + // docA updates it to 8 + // docB updates it to 9 + // docA.toJson() should return { list: [0, 1, 8] } + // docB.toJson() should return { list: [0, 1, 9] } + + listB.delete(2, 1); + listB.insert(2, 9); + expect(docB.toJson()).toStrictEqual({ list: [0, 1, 9] }); + listA.delete(2, 1); + listA.insert(2, 8); + expect(docA.toJson()).toStrictEqual({ list: [0, 1, 8] }); + } + + { + // Merge docA and docB + docA.import(docB.exportFrom(docA.version())); + docB.import(docA.exportFrom(docB.version())); + } + + expect(docA.toJson()).toStrictEqual({ list: [0, 1, 8, 9] }); + expect(docB.toJson()).toStrictEqual({ list: [0, 1, 8, 9] }); }) Deno.test("MovableList", () => { - let list = new LoroMovableList(); - list.push(0); - list.push("1"); + const docA = new Loro(); + docA.setPeerId("1"); + const listA = docA.getMovableList("list"); + listA.push(0); + listA.push(1); + listA.push(2); + const bytes: Uint8Array = docA.exportSnapshot(); + + const docB = Loro.fromSnapshot(bytes); + docB.setPeerId("2"); + const listB = docB.getMovableList("list"); + { + // Concurrently docA and docB update element at index 2 + // docA updates it to 8 + // docB updates it to 9 + // docA.toJson() should return { list: [0, 1, 8] } + // docB.toJson() should return { list: [0, 1, 9] } + + listA.set(2, 8); + expect(docA.toJson()).toStrictEqual({ list: [0, 1, 8] }); + listB.set(2, 9); + expect(docB.toJson()).toStrictEqual({ list: [0, 1, 9] }); + } + + { + // Merge docA and docB + docA.import(docB.exportFrom(docA.version())); + docB.import(docA.exportFrom(docB.version())); + } + + // Converge to [0, 1, 9] because docB has larger peerId thus larger logical time + expect(docA.toJson()).toStrictEqual({ list: [0, 1, 9] }); + expect(docB.toJson()).toStrictEqual({ list: [0, 1, 9] }); + + { + // Concurrently docA and docB move element at index 0 + // docA moves it to 2 + // docB moves it to 1 + // docA.toJson() should return { list: [1, 9, 0] } + // docB.toJson() should return { list: [1, 0, 9] } + + listA.move(0, 2); + listB.move(0, 1); + expect(docA.toJson()).toStrictEqual({ list: [1, 9, 0] }); + expect(docB.toJson()).toStrictEqual({ list: [1, 0, 9] }); + } + + { + // Merge docA and docB + docA.import(docB.exportFrom(docA.version())); + docB.import(docA.exportFrom(docB.version())); + } + + // Converge to [1, 0, 9] because docB has larger peerId thus larger logical time + expect(docA.toJson()).toStrictEqual({ list: [1, 0, 9] }); + expect(docB.toJson()).toStrictEqual({ list: [1, 0, 9] }); +}) + + +Deno.test("List Cursors", () => { const doc = new Loro(); - const map = doc.getMap("root"); - list = map.setContainer("list", list); - expect(doc.toJson()).toStrictEqual({ root: { list: [0, "1"] } }); - list.move(0, 1); - expect(doc.toJson()).toStrictEqual({ root: { list: ["1", 0] } }); - // Uint8Array is a special type in Loro - list.set(1, new Uint8Array([1, 2, 3])); - expect(doc.toJson()).toStrictEqual({ root: { list: ["1", new Uint8Array([1, 2, 3])] } }); - const text = list.setContainer(0, new LoroText()); - text.insert(0, "Hello") - expect(doc.toJson()).toStrictEqual({ root: { list: ["Hello", new Uint8Array([1, 2, 3])] } }); + doc.setPeerId("1"); + const list = doc.getList("list"); + list.push("Hello"); + list.push("World"); + const cursor = list.getCursor(1)!; + expect(cursor.pos()).toStrictEqual({ peer: "1", counter: 1 }); + + const encodedCursor: Uint8Array = cursor.encode(); + const exported: Uint8Array = doc.exportSnapshot(); + + // Sending the exported snapshot and the encoded cursor to peer 2 + // Peer 2 will decode the cursor and get the position of the cursor in the list + // Peer 2 will then insert "Hello" at the beginning of the list + + const docB = new Loro(); + docB.setPeerId("2"); + const listB = docB.getList("list"); + docB.import(exported); + listB.insert(0, "Foo"); + expect(docB.toJson()).toStrictEqual({ list: ["Foo", "Hello", "World"] }); + const cursorB = Cursor.decode(encodedCursor); + { + // The cursor position is shifted to the right by 1 + const pos = docB.getCursorPos(cursorB); + expect(pos.offset).toBe(2); + } + listB.insert(1, "Bar"); + expect(docB.toJson()).toStrictEqual({ list: ["Foo", "Bar", "Hello", "World"] }); + { + // The cursor position is shifted to the right by 1 + const pos = docB.getCursorPos(cursorB); + expect(pos.offset).toBe(3); + } + listB.delete(3, 1); + expect(docB.toJson()).toStrictEqual({ list: ["Foo", "Bar", "Hello"] }); + { + // The position cursor points to is now deleted, + // but it should still get the position + const pos = docB.getCursorPos(cursorB); + expect(pos.offset).toBe(3); + // It will also offer a update on the cursor position. + // + // It's because the old cursor position is deleted, `doc.getCursorPos()` will slow down over time. + // Internally, it needs to traverse the related history to find the correct position for a deleted + // cursor position. + // + // After refreshing the cursor, the performance of `doc.getCursorPos()` will improve. + expect(pos.update).toBeDefined(); + const newCursor: Cursor = pos.update!; + // The new cursor position is undefined because the cursor is at the end of the list + expect(newCursor.pos()).toBeUndefined(); + // The side is 1 because the cursor is at the right end of the list + expect(newCursor.side()).toBe(1); + + const newPos = docB.getCursorPos(newCursor); + // The offset doesn't changed + expect(newPos.offset).toBe(3); + // The update is undefined because the cursor no longer needs to be updated + expect(newPos.update).toBeUndefined(); + } }) diff --git a/08_map.test.ts b/08_map.test.ts new file mode 100644 index 0000000..0702f96 --- /dev/null +++ b/08_map.test.ts @@ -0,0 +1,26 @@ +import { Loro, LoroText } from "npm:loro-crdt@0.15.0"; +import { expect } from "npm:expect@29.7.0"; + +Deno.test("LoroMap", () => { + const docA = new Loro(); + docA.setPeerId("0"); + const docB = new Loro(); + docB.setPeerId("1"); + + const mapA = docA.getMap("map"); + const mapB = docB.getMap("map"); + + mapA.set("a", 1); + const textB = mapB.setContainer("a", new LoroText()); + textB.insert(0, "Hi"); + + expect(docA.toJson()).toStrictEqual({ map: { a: 1 } }); + expect(docB.toJson()).toStrictEqual({ map: { a: "Hi" } }); + + docA.import(docB.exportSnapshot()); + docB.import(docA.exportSnapshot()); + + expect(docA.toJson()).toStrictEqual({ map: { a: "Hi" } }); + expect(docB.toJson()).toStrictEqual({ map: { a: "Hi" } }); +}); + diff --git a/09_composition.test.ts b/09_composition.test.ts new file mode 100644 index 0000000..6c871c9 --- /dev/null +++ b/09_composition.test.ts @@ -0,0 +1,39 @@ +import { Loro, LoroList, LoroText } from "npm:loro-crdt@0.15.0"; +import { expect } from "npm:expect@29.7.0"; + +Deno.test("Composition", async () => { + const doc = new Loro(); + const map = doc.getMap("map"); + let callTimes = 0; + map.subscribe((_event) => { + callTimes++; + }); + + // Create a sub container for map + // { map: { list: [] } } + const list = map.setContainer("list", new LoroList()); + list.push(0); + list.push(1); + + // Create a sub container for list + // { map: { list: [0, 1, LoroText] } } + const text = list.insertContainer(2, new LoroText()); + expect(doc.toJson()).toStrictEqual({ map: { list: [0, 1, ""] } }); + { + // Commit will trigger the event, because list is a sub container of map + doc.commit(); + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(callTimes).toBe(1); + } + + text.insert(0, "Hello, "); + text.insert(7, "World!"); + expect(doc.toJson()).toStrictEqual({ map: { list: [0, 1, "Hello, World!"] } }); + { + // Commit will trigger the event, because text is a descendant of map + doc.commit(); + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(callTimes).toBe(2); + } +}); +