import { describe, expect, it, vi } from "@suaveplan/testing/runner"; import type { Patch } from "../operations/operations.js"; import { createSync, type SyncDelta, SyncManager } from "./sync.js"; function makeDelta(patch: Patch, seq: number): SyncDelta { return { patch, sequenceNumber: seq, timestamp: Date.now() }; } const patchA: Patch = [{ op: "replace", path: ["a"], value: 1 }]; const patchB: Patch = [{ op: "replace", path: ["b"], value: 2 }]; const patchConflict: Patch = [{ op: "replace", path: ["a"], value: 99 }]; // same path as patchA describe("SyncManager - push", () => { it("returns delta with incremented sequence", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); const d1 = s.push(patchA); expect(d1.sequenceNumber).toBe(1); const d2 = s.push(patchB); expect(d2.sequenceNumber).toBe(2); }); it("returned delta contains the patch", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); const d = s.push(patchA); expect(d.patch).toEqual(patchA); }); it("returned delta has a timestamp", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); const d = s.push(patchA); expect(typeof d.timestamp).toBe("number"); expect(d.timestamp).toBeGreaterThan(0); }); }); describe("SyncManager - pull (no conflict)", () => { it("returns full remote patch when no conflict", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); s.push(patchA); const rebased = s.pull(makeDelta(patchB, 2)); expect(rebased).toEqual(patchB); }); it("accepts remote delta with no pending local", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); const rebased = s.pull(makeDelta(patchA, 1)); expect(rebased).toEqual(patchA); }); }); describe("SyncManager - local-wins", () => { it("filters out conflicting remote ops", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); s.push(patchA); // local change on ["a"] // Remote also changes ["a"] — conflict const rebased = s.pull(makeDelta(patchConflict, 2)); expect(rebased).toEqual([]); // conflicting op filtered out }); it("keeps non-conflicting remote ops", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); s.push(patchA); // local change on ["a"] // Remote changes ["a"] and ["b"] — only ["a"] conflicts const mixed: Patch = [ { op: "replace", path: ["a"], value: 99 }, { op: "replace", path: ["b"], value: 42 }, ]; const rebased = s.pull(makeDelta(mixed, 2)); expect(rebased).toEqual([{ op: "replace", path: ["b"], value: 42 }]); }); }); describe("SyncManager - remote-wins", () => { it("returns full remote patch and removes conflicting local", { timeout: 5000, }, () => { const s = createSync({ conflictResolution: "remote-wins" }); s.push(patchA); // local change on ["a"] const rebased = s.pull(makeDelta(patchConflict, 2)); expect(rebased).toEqual(patchConflict); // full remote patch }); }); describe("SyncManager - reject", () => { it("throws on conflict", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "reject" }); s.push(patchA); expect(() => s.pull(makeDelta(patchConflict, 2))).toThrow("conflict"); }); it("does not throw for non-conflicting remote", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "reject" }); s.push(patchA); expect(() => s.pull(makeDelta(patchB, 2))).not.toThrow(); }); }); describe("SyncManager - onDelta", () => { it("listener called on push", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); const listener = vi.fn(); s.onDelta(listener); s.push(patchA); expect(listener).toHaveBeenCalledTimes(1); const delta: SyncDelta = listener.mock.calls[0][0]; expect(delta.patch).toEqual(patchA); expect(delta.sequenceNumber).toBe(1); }); it("unsubscribe stops listener", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); const listener = vi.fn(); const unsub = s.onDelta(listener); unsub(); s.push(patchA); expect(listener).not.toHaveBeenCalled(); }); it("multiple listeners all notified", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); const l1 = vi.fn(); const l2 = vi.fn(); s.onDelta(l1); s.onDelta(l2); s.push(patchA); expect(l1).toHaveBeenCalledTimes(1); expect(l2).toHaveBeenCalledTimes(1); }); }); describe("SyncManager - reset", () => { it("clears sequence counter and pending", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); s.push(patchA); s.push(patchB); s.reset(); // After reset, a new push should start from sequence 1 const d = s.push(patchA); expect(d.sequenceNumber).toBe(1); }); it("after reset, pull with conflicting delta does not conflict", { timeout: 5000, }, () => { const s = createSync({ conflictResolution: "reject" }); s.push(patchA); s.reset(); // clears pending // Now there's no local pending, so no conflict expect(() => s.pull(makeDelta(patchConflict, 1))).not.toThrow(); }); }); describe("SyncManager - sequence number advancement", () => { it("sequence advances on push", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); expect(s.push(patchA).sequenceNumber).toBe(1); expect(s.push(patchB).sequenceNumber).toBe(2); }); it("sequence updates when remote has higher seq", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); s.pull(makeDelta(patchA, 100)); // Next push should be 101 const d = s.push(patchB); expect(d.sequenceNumber).toBe(101); }); it("sequence does NOT update when remote has lower seq", { timeout: 5000, }, () => { const s = createSync({ conflictResolution: "local-wins" }); s.push(patchA); // seq = 1 s.push(patchB); // seq = 2 // Pull with seq=1 (lower than current 2) — should not update sequence s.pull(makeDelta(patchA, 1)); // Next push should be 3, not 2 const d = s.push(patchA); expect(d.sequenceNumber).toBe(3); }); }); describe("createSync factory", () => { it("returns a SyncManager instance", { timeout: 5000 }, () => { const s = createSync({ conflictResolution: "local-wins" }); expect(s).toBeInstanceOf(SyncManager); }); });