Initial Commit
This commit is contained in:
187
packages/foundation/data/diff/src/sync/sync.test.ts
Normal file
187
packages/foundation/data/diff/src/sync/sync.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user