import { describe, it, expect } from "vitest"; import { emptyArtifact, type Artifact } from "../src/model"; import { mergeArtifacts } from "../src/mergeArtifacts"; import { expectValidSidecar } from "./helpers/validateSidecar"; const FP = { text: "alpha", before: "", after: "", lineHint: 0 }; function base(): Artifact { const a = emptyArtifact("docs/x.md"); a.anchors["a_1"] = { fingerprint: FP }; a.threads.push({ id: "t_1", anchorId: "a_1", status: "open", messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }], }); return a; } const clone = (a: Artifact): Artifact => JSON.parse(JSON.stringify(a)); describe("mergeArtifacts (F5 SLICE-3, INV-17)", () => { it("reply-append vs disjoint edit unions cleanly, no conflicts (the forge case)", () => { const ours = base(); ours.attributions.push({ id: "at_1", anchorId: "a_1", author: { kind: "human", id: "ben" }, createdAt: "2026-06-10T01:00:00.000Z", updatedAt: "2026-06-10T01:00:00.000Z", }); const theirs = base(); theirs.threads[0].messages.push({ id: "m_2", author: { kind: "human", id: "forge", email: "forge@example.org" }, body: "reply", createdAt: "2026-06-10T02:00:00.000Z", }); const { merged, conflicts } = mergeArtifacts(ours, theirs); expect(conflicts).toEqual([]); expect(merged.threads[0].messages.map((m) => m.id)).toEqual(["m_1", "m_2"]); expect(merged.attributions.map((a) => a.id)).toEqual(["at_1"]); expectValidSidecar(merged); }); it("both sides append to the same thread: messages interleave by createdAt, no conflicts", () => { const ours = base(); ours.threads[0].messages.push({ id: "m_b", author: { kind: "human", id: "ben" }, body: "B", createdAt: "2026-06-10T03:00:00.000Z" }); const theirs = base(); theirs.threads[0].messages.push({ id: "m_a", author: { kind: "human", id: "forge" }, body: "A", createdAt: "2026-06-10T02:00:00.000Z" }); const { merged, conflicts } = mergeArtifacts(ours, theirs); expect(conflicts).toEqual([]); expect(merged.threads[0].messages.map((m) => m.id)).toEqual(["m_1", "m_a", "m_b"]); }); it("same attribution id divergent: newer updatedAt wins and the id is reported", () => { const rec = { id: "at_1", anchorId: "a_1", author: { kind: "human", id: "ben" } as const, createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z", }; const ours = base(); ours.attributions.push({ ...rec, updatedAt: "2026-06-10T05:00:00.000Z" }); const theirs = base(); theirs.attributions.push({ ...rec, updatedAt: "2026-06-10T04:00:00.000Z", turnId: "turn-x" }); const { merged, conflicts } = mergeArtifacts(ours, theirs); expect(conflicts).toEqual(["at_1"]); expect(merged.attributions[0].updatedAt).toBe("2026-06-10T05:00:00.000Z"); expect(merged.attributions[0].turnId).toBeUndefined(); }); it("divergent same-id proposals: deterministic tie-break, id reported, symmetric result", () => { const p = { id: "p_1", anchorId: "a_1", replacement: "AAA", author: { kind: "human", id: "ben" } as const, createdAt: "2026-06-10T00:00:00.000Z", }; const ours = base(); ours.proposals.push({ ...p }); const theirs = base(); theirs.proposals.push({ ...p, replacement: "ZZZ" }); const r1 = mergeArtifacts(ours, theirs); const r2 = mergeArtifacts(theirs, ours); expect(r1.conflicts).toEqual(["p_1"]); expect(r1.merged.proposals[0].replacement).toBe("ZZZ"); expect(r2.merged.proposals[0].replacement).toBe("ZZZ"); }); it("thread status divergence resolves deterministically and reports the thread id", () => { const ours = base(); const theirs = clone(ours); theirs.threads[0].status = "resolved"; const { merged, conflicts } = mergeArtifacts(ours, theirs); expect(merged.threads[0].status).toBe("resolved"); expect(conflicts).toEqual(["t_1"]); }); it("divergent same-id messages tie-break symmetrically and report the message id", () => { const ours = base(); const theirs = clone(ours); theirs.threads[0].messages[0].body = "hi (edited)"; const r1 = mergeArtifacts(ours, theirs); const r2 = mergeArtifacts(theirs, ours); expect(r1.conflicts).toEqual(["m_1"]); expect(r2.conflicts).toEqual(["m_1"]); // deterministic + symmetric: both directions pick the same winner expect(r1.merged.threads[0].messages[0].body).toBe(r2.merged.threads[0].messages[0].body); }); it("anchors union by key; divergent same key reported", () => { const ours = base(); const theirs = base(); theirs.anchors["a_2"] = { fingerprint: { ...FP, text: "beta" } }; const clean = mergeArtifacts(ours, theirs); expect(Object.keys(clean.merged.anchors).sort()).toEqual(["a_1", "a_2"]); expect(clean.conflicts).toEqual([]); const theirs2 = clone(ours); theirs2.anchors["a_1"] = { fingerprint: { ...FP, lineHint: 9 } }; const dirty = mergeArtifacts(ours, theirs2); expect(dirty.conflicts).toEqual(["a_1"]); }); it("unknown fields ride with the winning record and root unknowns union", () => { const ours = base() as Artifact & Record; ours.futureSection = [1]; const theirs = base() as Artifact & Record; theirs.otherFuture = "x"; const { merged, conflicts } = mergeArtifacts(ours, theirs); expect((merged as unknown as Record).futureSection).toEqual([1]); expect((merged as unknown as Record).otherFuture).toBe("x"); expect(conflicts).toEqual([]); }); it("throws on differing majors (INV-16)", () => { const ours = base(); const theirs = { ...base(), schemaVersion: 2 }; expect(() => mergeArtifacts(ours, theirs)).toThrow(/schemaVersion/); }); });