Files
suaveplan/packages/foundation/data/diff/src/sync/sync.test.ts
2026-05-27 23:47:53 +01:00

188 lines
7.0 KiB
TypeScript

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);
});
});