aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/sync/crdt/document-manager.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/sync/crdt/document-manager.test.ts')
-rw-r--r--src/client/sync/crdt/document-manager.test.ts606
1 files changed, 606 insertions, 0 deletions
diff --git a/src/client/sync/crdt/document-manager.test.ts b/src/client/sync/crdt/document-manager.test.ts
new file mode 100644
index 0000000..7c0fc00
--- /dev/null
+++ b/src/client/sync/crdt/document-manager.test.ts
@@ -0,0 +1,606 @@
+import * as Automerge from "@automerge/automerge";
+import { describe, expect, it } from "vitest";
+import type { LocalCard, LocalDeck, LocalReviewLog } from "../../db/index";
+import { CardState, Rating } from "../../db/index";
+import {
+ applyChanges,
+ cardToCrdtDocument,
+ crdtDocumentToCard,
+ crdtDocumentToDeck,
+ crdtDocumentToReviewLog,
+ createDocument,
+ createDocumentFromEntity,
+ createEmptyDocument,
+ deckToCrdtDocument,
+ getChanges,
+ getLastModified,
+ hasConflicts,
+ isDeleted,
+ loadDocument,
+ loadIncremental,
+ mergeDocuments,
+ reviewLogToCrdtDocument,
+ saveDocument,
+ saveIncremental,
+ updateDocument,
+} from "./document-manager";
+import {
+ type CrdtCardDocument,
+ type CrdtDeckDocument,
+ CrdtEntityType,
+ type CrdtReviewLogDocument,
+} from "./types";
+
+describe("createDocument", () => {
+ it("should create an Automerge document from data", () => {
+ const data = { name: "test", value: 42 };
+ const doc = createDocument(data);
+
+ expect(doc.name).toBe("test");
+ expect(doc.value).toBe(42);
+ });
+
+ it("should create a CRDT deck document", () => {
+ const deckData: CrdtDeckDocument = {
+ meta: {
+ entityId: "deck-1",
+ lastModified: Date.now(),
+ deleted: false,
+ },
+ data: {
+ userId: "user-1",
+ name: "My Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: Date.now(),
+ deletedAt: null,
+ },
+ };
+
+ const doc = createDocument(deckData);
+ expect(doc.meta.entityId).toBe("deck-1");
+ expect(doc.data.name).toBe("My Deck");
+ });
+});
+
+describe("updateDocument", () => {
+ it("should update document and return new immutable copy", () => {
+ const doc = createDocument({ count: 0 });
+ const updated = updateDocument(doc, (d) => {
+ d.count = 1;
+ });
+
+ expect(updated.count).toBe(1);
+ // Original document should be frozen after change
+ });
+
+ it("should update nested properties", () => {
+ const doc = createDocument({
+ meta: { entityId: "test" },
+ data: { name: "original" },
+ });
+
+ const updated = updateDocument(doc, (d) => {
+ d.data.name = "updated";
+ });
+
+ expect(updated.data.name).toBe("updated");
+ });
+});
+
+describe("mergeDocuments", () => {
+ it("should merge two documents without conflicts", () => {
+ const doc1 = createDocument({ a: 1, b: 2 });
+ const doc2 = Automerge.clone(doc1);
+
+ const updated1 = updateDocument(doc1, (d) => {
+ d.a = 10;
+ });
+ const updated2 = updateDocument(doc2, (d) => {
+ d.b = 20;
+ });
+
+ const result = mergeDocuments(updated1, updated2);
+
+ expect(result.merged.a).toBe(10);
+ expect(result.merged.b).toBe(20);
+ expect(result.hasChanges).toBe(true);
+ });
+
+ it("should detect when no changes occurred", () => {
+ const doc1 = createDocument({ a: 1 });
+ const doc2 = Automerge.clone(doc1);
+
+ const result = mergeDocuments(doc1, doc2);
+ expect(result.hasChanges).toBe(false);
+ });
+
+ it("should return binary representation of merged document", () => {
+ const doc1 = createDocument({ value: 1 });
+ const doc2 = Automerge.clone(doc1);
+
+ const result = mergeDocuments(doc1, doc2);
+ expect(result.binary).toBeInstanceOf(Uint8Array);
+ expect(result.binary.length).toBeGreaterThan(0);
+ });
+});
+
+describe("getChanges and applyChanges", () => {
+ it("should get and apply changes between documents", () => {
+ const doc1 = createDocument({ value: 1 });
+ const doc2 = updateDocument(doc1, (d) => {
+ d.value = 2;
+ });
+
+ const changes = getChanges(doc1, doc2);
+ expect(changes.length).toBeGreaterThan(0);
+
+ // Create a clone of doc1 and apply changes
+ const doc3 = Automerge.clone(doc1);
+ const result = applyChanges(doc3, changes);
+ expect(result.value).toBe(2);
+ });
+});
+
+describe("saveDocument and loadDocument", () => {
+ it("should serialize and deserialize document", () => {
+ const original: CrdtDeckDocument = {
+ meta: {
+ entityId: "deck-123",
+ lastModified: 1234567890,
+ deleted: false,
+ },
+ data: {
+ userId: "user-1",
+ name: "Test Deck",
+ description: "A test deck",
+ newCardsPerDay: 15,
+ createdAt: 1234567890,
+ deletedAt: null,
+ },
+ };
+
+ const doc = createDocument(original);
+ const binary = saveDocument(doc);
+
+ expect(binary).toBeInstanceOf(Uint8Array);
+
+ const loaded = loadDocument<CrdtDeckDocument>(binary);
+ expect(loaded.meta.entityId).toBe("deck-123");
+ expect(loaded.data.name).toBe("Test Deck");
+ expect(loaded.data.newCardsPerDay).toBe(15);
+ });
+});
+
+describe("saveIncremental and loadIncremental", () => {
+ it("should save and load incremental changes", () => {
+ const doc1 = createDocument({ value: 1 });
+ const doc2 = updateDocument(doc1, (d) => {
+ d.value = 2;
+ });
+
+ const incremental = saveIncremental(doc2);
+ expect(incremental).toBeInstanceOf(Uint8Array);
+
+ // Create a clone and load incremental
+ const doc3 = Automerge.clone(doc1);
+ const result = loadIncremental(doc3, incremental);
+ expect(result.value).toBe(2);
+ });
+});
+
+describe("createEmptyDocument", () => {
+ it("should create empty deck document", () => {
+ const doc = createEmptyDocument(CrdtEntityType.Deck);
+ expect(doc.meta.entityId).toBe("");
+ expect(doc.meta.deleted).toBe(false);
+ expect(doc.data.name).toBe("");
+ expect(doc.data.newCardsPerDay).toBe(20);
+ });
+
+ it("should create empty card document", () => {
+ const doc = createEmptyDocument(CrdtEntityType.Card);
+ expect(doc.meta.entityId).toBe("");
+ expect(doc.data.state).toBe(0);
+ expect(doc.data.reps).toBe(0);
+ });
+
+ it("should create empty note type document", () => {
+ const doc = createEmptyDocument(CrdtEntityType.NoteType);
+ expect(doc.data.frontTemplate).toBe("");
+ expect(doc.data.isReversible).toBe(false);
+ });
+
+ it("should create empty review log document", () => {
+ const doc = createEmptyDocument(CrdtEntityType.ReviewLog);
+ expect(doc.data.rating).toBe(3);
+ expect(doc.data.durationMs).toBeNull();
+ });
+});
+
+describe("deckToCrdtDocument and crdtDocumentToDeck", () => {
+ it("should convert LocalDeck to CRDT document", () => {
+ const now = new Date();
+ const deck: LocalDeck = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "My Deck",
+ description: "A deck for testing",
+ newCardsPerDay: 25,
+ createdAt: now,
+ updatedAt: now,
+ deletedAt: null,
+ syncVersion: 1,
+ _synced: true,
+ };
+
+ const crdtDoc = deckToCrdtDocument(deck);
+
+ expect(crdtDoc.meta.entityId).toBe("deck-1");
+ expect(crdtDoc.meta.deleted).toBe(false);
+ expect(crdtDoc.data.name).toBe("My Deck");
+ expect(crdtDoc.data.description).toBe("A deck for testing");
+ expect(crdtDoc.data.newCardsPerDay).toBe(25);
+ expect(crdtDoc.data.createdAt).toBe(now.getTime());
+ });
+
+ it("should convert deleted deck correctly", () => {
+ const now = new Date();
+ const deletedAt = new Date(now.getTime() + 1000);
+ const deck: LocalDeck = {
+ id: "deck-2",
+ userId: "user-1",
+ name: "Deleted Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: now,
+ updatedAt: deletedAt,
+ deletedAt: deletedAt,
+ syncVersion: 2,
+ _synced: false,
+ };
+
+ const crdtDoc = deckToCrdtDocument(deck);
+
+ expect(crdtDoc.meta.deleted).toBe(true);
+ expect(crdtDoc.data.deletedAt).toBe(deletedAt.getTime());
+ });
+
+ it("should convert CRDT document back to LocalDeck", () => {
+ const now = Date.now();
+ const crdtDoc: CrdtDeckDocument = {
+ meta: {
+ entityId: "deck-3",
+ lastModified: now,
+ deleted: false,
+ },
+ data: {
+ userId: "user-2",
+ name: "Converted Deck",
+ description: "Converted from CRDT",
+ newCardsPerDay: 30,
+ createdAt: now - 10000,
+ deletedAt: null,
+ },
+ };
+
+ const localDeck = crdtDocumentToDeck(crdtDoc);
+
+ expect(localDeck.id).toBe("deck-3");
+ expect(localDeck.userId).toBe("user-2");
+ expect(localDeck.name).toBe("Converted Deck");
+ expect(localDeck.newCardsPerDay).toBe(30);
+ expect(localDeck.deletedAt).toBeNull();
+ expect(localDeck.syncVersion).toBe(0); // Set by sync layer
+ });
+});
+
+describe("cardToCrdtDocument and crdtDocumentToCard", () => {
+ it("should convert LocalCard to CRDT document", () => {
+ const now = new Date();
+ const card: LocalCard = {
+ id: "card-1",
+ deckId: "deck-1",
+ noteId: "note-1",
+ isReversed: false,
+ front: "What is the capital?",
+ back: "Tokyo",
+ state: CardState.New,
+ due: now,
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: now,
+ updatedAt: now,
+ deletedAt: null,
+ syncVersion: 1,
+ _synced: true,
+ };
+
+ const crdtDoc = cardToCrdtDocument(card);
+
+ expect(crdtDoc.meta.entityId).toBe("card-1");
+ expect(crdtDoc.data.front).toBe("What is the capital?");
+ expect(crdtDoc.data.back).toBe("Tokyo");
+ expect(crdtDoc.data.state).toBe(CardState.New);
+ expect(crdtDoc.data.due).toBe(now.getTime());
+ });
+
+ it("should convert reviewed card correctly", () => {
+ const now = new Date();
+ const lastReview = new Date(now.getTime() - 86400000);
+ const card: LocalCard = {
+ id: "card-2",
+ deckId: "deck-1",
+ noteId: "note-1",
+ isReversed: true,
+ front: "Tokyo",
+ back: "What is the capital?",
+ state: CardState.Review,
+ due: now,
+ stability: 5.5,
+ difficulty: 0.3,
+ elapsedDays: 1,
+ scheduledDays: 4,
+ reps: 3,
+ lapses: 0,
+ lastReview: lastReview,
+ createdAt: new Date(now.getTime() - 100000),
+ updatedAt: now,
+ deletedAt: null,
+ syncVersion: 5,
+ _synced: true,
+ };
+
+ const crdtDoc = cardToCrdtDocument(card);
+
+ expect(crdtDoc.data.isReversed).toBe(true);
+ expect(crdtDoc.data.state).toBe(CardState.Review);
+ expect(crdtDoc.data.stability).toBe(5.5);
+ expect(crdtDoc.data.reps).toBe(3);
+ expect(crdtDoc.data.lastReview).toBe(lastReview.getTime());
+ });
+
+ it("should convert CRDT document back to LocalCard", () => {
+ const now = Date.now();
+ const crdtDoc: CrdtCardDocument = {
+ meta: {
+ entityId: "card-3",
+ lastModified: now,
+ deleted: false,
+ },
+ data: {
+ deckId: "deck-1",
+ noteId: "note-1",
+ isReversed: false,
+ front: "Front text",
+ back: "Back text",
+ state: CardState.Learning,
+ due: now + 3600000,
+ stability: 1.0,
+ difficulty: 0.5,
+ elapsedDays: 0,
+ scheduledDays: 1,
+ reps: 1,
+ lapses: 0,
+ lastReview: now,
+ createdAt: now - 10000,
+ deletedAt: null,
+ },
+ };
+
+ const localCard = crdtDocumentToCard(crdtDoc);
+
+ expect(localCard.id).toBe("card-3");
+ expect(localCard.front).toBe("Front text");
+ expect(localCard.state).toBe(CardState.Learning);
+ expect(localCard.lastReview).toBeInstanceOf(Date);
+ expect(localCard.lastReview?.getTime()).toBe(now);
+ });
+});
+
+describe("reviewLogToCrdtDocument and crdtDocumentToReviewLog", () => {
+ it("should convert LocalReviewLog to CRDT document", () => {
+ const now = new Date();
+ const reviewLog: LocalReviewLog = {
+ id: "review-1",
+ cardId: "card-1",
+ userId: "user-1",
+ rating: Rating.Good,
+ state: CardState.Review,
+ scheduledDays: 4,
+ elapsedDays: 1,
+ reviewedAt: now,
+ durationMs: 5000,
+ syncVersion: 1,
+ _synced: true,
+ };
+
+ const crdtDoc = reviewLogToCrdtDocument(reviewLog);
+
+ expect(crdtDoc.meta.entityId).toBe("review-1");
+ expect(crdtDoc.data.rating).toBe(Rating.Good);
+ expect(crdtDoc.data.state).toBe(CardState.Review);
+ expect(crdtDoc.data.durationMs).toBe(5000);
+ expect(crdtDoc.data.reviewedAt).toBe(now.getTime());
+ });
+
+ it("should convert CRDT document back to LocalReviewLog", () => {
+ const now = Date.now();
+ const crdtDoc: CrdtReviewLogDocument = {
+ meta: {
+ entityId: "review-2",
+ lastModified: now,
+ deleted: false,
+ },
+ data: {
+ cardId: "card-2",
+ userId: "user-2",
+ rating: Rating.Hard,
+ state: CardState.Relearning,
+ scheduledDays: 1,
+ elapsedDays: 0,
+ reviewedAt: now,
+ durationMs: null,
+ },
+ };
+
+ const localReviewLog = crdtDocumentToReviewLog(crdtDoc);
+
+ expect(localReviewLog.id).toBe("review-2");
+ expect(localReviewLog.rating).toBe(Rating.Hard);
+ expect(localReviewLog.durationMs).toBeNull();
+ });
+});
+
+describe("createDocumentFromEntity", () => {
+ it("should create document from LocalDeck", () => {
+ const now = new Date();
+ const deck: LocalDeck = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Test",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: now,
+ updatedAt: now,
+ deletedAt: null,
+ syncVersion: 0,
+ _synced: false,
+ };
+
+ const doc = createDocumentFromEntity(CrdtEntityType.Deck, deck);
+ expect(doc.meta.entityId).toBe("deck-1");
+ expect(doc.data.name).toBe("Test");
+ });
+
+ it("should create document from LocalCard", () => {
+ const now = new Date();
+ const card: LocalCard = {
+ id: "card-1",
+ deckId: "deck-1",
+ noteId: "note-1",
+ isReversed: false,
+ front: "Q",
+ back: "A",
+ state: CardState.New,
+ due: now,
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: now,
+ updatedAt: now,
+ deletedAt: null,
+ syncVersion: 0,
+ _synced: false,
+ };
+
+ const doc = createDocumentFromEntity(CrdtEntityType.Card, card);
+ expect(doc.meta.entityId).toBe("card-1");
+ expect(doc.data.front).toBe("Q");
+ });
+});
+
+describe("hasConflicts", () => {
+ it("should detect no conflicts when documents are in sync", () => {
+ const doc1 = createDocument({ value: 1 });
+ const doc2 = Automerge.clone(doc1);
+
+ expect(hasConflicts(doc1, doc2)).toBe(false);
+ });
+
+ it("should detect conflicts when documents have diverged", () => {
+ const doc1 = createDocument({ value: 1 });
+ const doc2 = Automerge.clone(doc1);
+
+ // Make concurrent changes
+ const doc1Updated = updateDocument(doc1, (d) => {
+ d.value = 10;
+ });
+ const doc2Updated = updateDocument(doc2, (d) => {
+ d.value = 20;
+ });
+
+ expect(hasConflicts(doc1Updated, doc2Updated)).toBe(true);
+ });
+});
+
+describe("getLastModified", () => {
+ it("should return lastModified timestamp from document", () => {
+ const timestamp = 1234567890000;
+ const data: CrdtDeckDocument = {
+ meta: {
+ entityId: "deck-1",
+ lastModified: timestamp,
+ deleted: false,
+ },
+ data: {
+ userId: "user-1",
+ name: "Test",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: timestamp,
+ deletedAt: null,
+ },
+ };
+
+ const doc = createDocument(data);
+ expect(getLastModified(doc)).toBe(timestamp);
+ });
+});
+
+describe("isDeleted", () => {
+ it("should return false for non-deleted document", () => {
+ const data: CrdtDeckDocument = {
+ meta: {
+ entityId: "deck-1",
+ lastModified: Date.now(),
+ deleted: false,
+ },
+ data: {
+ userId: "user-1",
+ name: "Test",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: Date.now(),
+ deletedAt: null,
+ },
+ };
+
+ const doc = createDocument(data);
+ expect(isDeleted(doc)).toBe(false);
+ });
+
+ it("should return true for deleted document", () => {
+ const data: CrdtDeckDocument = {
+ meta: {
+ entityId: "deck-1",
+ lastModified: Date.now(),
+ deleted: true,
+ },
+ data: {
+ userId: "user-1",
+ name: "Test",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: Date.now(),
+ deletedAt: Date.now(),
+ },
+ };
+
+ const doc = createDocument(data);
+ expect(isDeleted(doc)).toBe(true);
+ });
+});
+
+// Note: getActorId tests are skipped because they require browser localStorage
+// which is not available in the Node.js test environment.
+// The function is tested implicitly through integration tests.