import { describe, expect, it, vi } from "vitest"; import { AUTO_MERGED, DEFAULT_SETTINGS, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, MISSING_OR_ERROR, type FilePathWithPrefix, type MetaEntry, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { ModuleConflictResolver } from "./ModuleConflictResolver"; function createModule(files: FilePathWithPrefix[] = []) { const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED); const tryAutoMerge = vi.fn(); const queueCheckFor = vi.fn(async () => undefined); const resolveByUserInteraction = vi.fn(async () => false); const core = { _services: { API: { addLog: vi.fn(), addCommand: vi.fn(), registerWindow: vi.fn(), addRibbonIcon: vi.fn(), registerProtocolHandler: vi.fn(), }, setting: { saveSettingData: vi.fn(async () => undefined), }, conflict: { resolveByNewest: vi.fn(async () => true), resolveByDeletingRevision, resolveByUserInteraction, queueCheckFor, }, appLifecycle: { isSuspended: vi.fn(() => false), }, replication: { replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })), }, vault: { getActiveFilePath: vi.fn(() => undefined), }, }, settings: DEFAULT_SETTINGS, fileHandler: { deleteRevisionFromDB: vi.fn(async () => true), dbToStorage: vi.fn(async () => true), }, databaseFileAccess: { getConflictedRevs: vi.fn(async () => []), storeContent: vi.fn(async () => true), }, localDatabase: { tryAutoMerge, }, storageAccess: { getFileNames: vi.fn(async () => files), }, } as any; Object.defineProperty(core, "services", { get: () => core._services }); const module = new ModuleConflictResolver(core); module._log = vi.fn(); return { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge }; } describe("ModuleConflictResolver bulk newest resolution", () => { it("retains the success notice for a non-bulk newest resolution", async () => { const { module } = createModule(); const path = "example.md" as FilePathWithPrefix; module.core.databaseFileAccess.fetchEntryMeta = vi.fn( async (_path: unknown, rev?: string): Promise => ({ _id: "doc-id", _rev: rev ?? "2-current", path, ctime: 1, mtime: rev ? 1 : 2, size: 0, children: [], type: "plain", eden: {}, }) as unknown as MetaEntry ); module.core.databaseFileAccess.getConflictedRevs = vi .fn() .mockResolvedValueOnce(["1-old"]) .mockResolvedValue([]); await (module as any)._anyResolveConflictByNewest(path); expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_NOTICE); }); it("logs a successful bulk newest resolution without displaying a notice", async () => { const { module } = createModule(); const path = "example.md" as FilePathWithPrefix; module.core.databaseFileAccess.fetchEntryMeta = vi.fn( async (_path: unknown, rev?: string): Promise => ({ _id: "doc-id", _rev: rev ?? "2-current", path, ctime: 1, mtime: rev ? 1 : 2, size: 0, children: [], type: "plain", eden: {}, }) as unknown as MetaEntry ); module.core.databaseFileAccess.getConflictedRevs = vi .fn() .mockResolvedValueOnce(["1-old"]) .mockResolvedValue([]); await (module as any)._anyResolveConflictByNewest(path, false); expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_INFO); }); it("updates notice-level progress once every ten checked files", async () => { const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix); const { module } = createModule(files); const resolveByNewest = vi.spyOn(module as any, "_anyResolveConflictByNewest").mockResolvedValue(true); await (module as any)._resolveAllConflictedFilesByNewerOnes(); expect(resolveByNewest).toHaveBeenCalledTimes(11); expect(resolveByNewest).toHaveBeenCalledWith(files[0], false); expect(module._log).toHaveBeenCalledWith( "Check and Processing 10 / 11", LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes" ); expect(module._log).toHaveBeenCalledTimes(3); }); }); describe("ModuleConflictResolver independent same-path creation", () => { const path = "independently-created.md" as FilePathWithPrefix; function leaf(rev: string, data: string, mtime: number) { return { rev, data, mtime, ctime: mtime, deleted: false, } as any; } it("collapses one duplicate revision when independently created files have identical content", async () => { const { module, resolveByDeletingRevision, tryAutoMerge } = createModule(); const leftLeaf = leaf("1-left", "Same content\n", 1000); const rightLeaf = leaf("1-right", "Same content\n", 2000); tryAutoMerge.mockResolvedValue({ leftRev: leftLeaf.rev, rightRev: rightLeaf.rev, leftLeaf, rightLeaf, }); const result = await module.checkConflictAndPerformAutoMerge(path); expect(result).toBe(AUTO_MERGED); expect(resolveByDeletingRevision).toHaveBeenCalledOnce(); expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same"); }); it("returns a manual diff when independently created files have different content", async () => { const { module, resolveByDeletingRevision, tryAutoMerge } = createModule(); const leftLeaf = leaf("1-left", "Left content\n", 1000); const rightLeaf = leaf("1-right", "Right content\n", 2000); tryAutoMerge.mockResolvedValue({ leftRev: leftLeaf.rev, rightRev: rightLeaf.rev, leftLeaf, rightLeaf, }); const result = await module.checkConflictAndPerformAutoMerge(path); expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf }); expect(result).toHaveProperty("diff"); expect(resolveByDeletingRevision).not.toHaveBeenCalled(); }); }); describe("ModuleConflictResolver sensible merge hand-off", () => { it("keeps an unreadable non-winner revision unresolved", async () => { const path = "missing-conflict-body.md" as FilePathWithPrefix; const { module, resolveByDeletingRevision, tryAutoMerge } = createModule(); tryAutoMerge.mockResolvedValue({ leftRev: "3-current", rightRev: "2-unreadable", leftLeaf: { rev: "3-current", data: "Readable current body\n", ctime: 1, mtime: 3, deleted: false, }, rightLeaf: false, }); const result = await module.checkConflictAndPerformAutoMerge(path); expect(result).toBe(MISSING_OR_ERROR); expect(resolveByDeletingRevision).not.toHaveBeenCalled(); }); it("stores the merged body and removes the resolved conflict leaf", async () => { const path = "sensible.md" as FilePathWithPrefix; const { module, resolveByDeletingRevision, tryAutoMerge } = createModule(); tryAutoMerge.mockResolvedValue({ result: "Title\nLeft changed\nRight changed\n", conflictedRev: "2-right", }); const result = await module.checkConflictAndPerformAutoMerge(path); expect(result).toBe(AUTO_MERGED); expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith( path, "Title\nLeft changed\nRight changed\n" ); expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible"); }); it("commits a sensible pair before rechecking the remaining manual pair", async () => { const path = "three-versions.md" as FilePathWithPrefix; const { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge } = createModule(); const remainingManualPair = { leftRev: "3-merged", rightRev: "2-third", leftLeaf: { rev: "3-merged", data: "Merged\n", ctime: 1, mtime: 3 }, rightLeaf: { rev: "2-third", data: "Overlapping\n", ctime: 1, mtime: 2 }, }; tryAutoMerge .mockResolvedValueOnce({ result: "Merged\n", conflictedRev: "2-second", }) .mockResolvedValueOnce(remainingManualPair); await (module as any)._resolveConflict(path); expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n"); expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible"); expect(queueCheckFor).toHaveBeenCalledWith(path); expect(resolveByUserInteraction).not.toHaveBeenCalled(); await (module as any)._resolveConflict(path); expect(tryAutoMerge).toHaveBeenCalledTimes(2); expect(resolveByUserInteraction).toHaveBeenCalledWith( path, expect.objectContaining({ left: remainingManualPair.leftLeaf, right: remainingManualPair.rightLeaf, }) ); }); });