/** * Comment Store Tests * * Covers the Yjs-backed comment store: * - add / addReply / remove / get / getAll * - resolve / unresolve * - observe * - collaboration (concurrent adds merge) */ import { describe, it, expect } from "vitest"; import * as Y from "yjs"; import { createCommentStore, type CommentData, type ReplyData } from "../src/editor/comments"; function makeStore() { const ydoc = new Y.Doc(); return { ydoc, store: createCommentStore(ydoc) }; } function baseComment(overrides: Partial> = {}) { return { id: "c1", author: "alice", authorColor: "#f00", text: "hello", createdAt: 1_000, resolved: false, ...overrides, } as Omit; } describe("comment-store — CRUD", () => { it("add() stores a comment with an empty replies array", () => { const { store } = makeStore(); store.add(baseComment()); const got = store.get("c1"); expect(got).toBeDefined(); expect(got!.text).toBe("hello"); expect(got!.replies).toEqual([]); }); it("getAll() returns comments sorted by createdAt", () => { const { store } = makeStore(); store.add(baseComment({ id: "late", createdAt: 3000 })); store.add(baseComment({ id: "early", createdAt: 1000 })); store.add(baseComment({ id: "mid", createdAt: 2000 })); const all = store.getAll(); expect(all.map((c) => c.id)).toEqual(["early", "mid", "late"]); }); it("remove() deletes a comment", () => { const { store } = makeStore(); store.add(baseComment()); store.remove("c1"); expect(store.get("c1")).toBeUndefined(); }); it("get() on a missing id returns undefined", () => { const { store } = makeStore(); expect(store.get("missing")).toBeUndefined(); }); }); describe("comment-store — replies", () => { const reply: ReplyData = { id: "r1", author: "bob", authorColor: "#00f", text: "ack", createdAt: 1500, }; it("addReply() appends to the replies array", () => { const { store } = makeStore(); store.add(baseComment()); store.addReply("c1", reply); store.addReply("c1", { ...reply, id: "r2", text: "ack2" }); const got = store.get("c1")!; expect(got.replies.map((r) => r.id)).toEqual(["r1", "r2"]); expect(got.replies[1].text).toBe("ack2"); }); it("addReply() on a missing comment is a no-op", () => { const { store } = makeStore(); store.addReply("missing", reply); expect(store.get("missing")).toBeUndefined(); }); }); describe("comment-store — resolve/unresolve", () => { it("resolve() marks the comment resolved and records the resolver", () => { const { store } = makeStore(); store.add(baseComment()); store.resolve("c1", "carol"); const got = store.get("c1")!; expect(got.resolved).toBe(true); expect(got.resolvedBy).toBe("carol"); expect(typeof got.resolvedAt).toBe("number"); }); it("unresolve() clears the resolved metadata", () => { const { store } = makeStore(); store.add(baseComment()); store.resolve("c1", "carol"); store.unresolve("c1"); const got = store.get("c1")!; expect(got.resolved).toBe(false); expect(got.resolvedBy).toBeUndefined(); expect(got.resolvedAt).toBeUndefined(); }); it("resolve() on a missing comment is a no-op", () => { const { store } = makeStore(); store.resolve("missing", "carol"); expect(store.get("missing")).toBeUndefined(); }); }); describe("comment-store — observe", () => { it("fires on add and remove, stops after unsubscribe", () => { const { store } = makeStore(); let count = 0; const unsub = store.observe(() => { count++; }); store.add(baseComment({ id: "a" })); store.add(baseComment({ id: "b" })); store.remove("a"); expect(count).toBe(3); unsub(); store.remove("b"); expect(count).toBe(3); }); }); describe("comment-store — collaboration", () => { it("concurrent adds in different docs both survive after sync", () => { const docA = new Y.Doc(); const docB = new Y.Doc(); const a = createCommentStore(docA); const b = createCommentStore(docB); a.add(baseComment({ id: "from-a" })); b.add(baseComment({ id: "from-b", author: "bob", authorColor: "#0f0" })); Y.applyUpdate(docA, Y.encodeStateAsUpdate(docB)); Y.applyUpdate(docB, Y.encodeStateAsUpdate(docA)); expect(a.getAll().map((c) => c.id).sort()).toEqual(["from-a", "from-b"]); expect(b.getAll().map((c) => c.id).sort()).toEqual(["from-a", "from-b"]); }); });