mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-29 14:57:05 +00:00
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
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<MetaEntry> =>
|
|
({
|
|
_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<MetaEntry> =>
|
|
({
|
|
_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,
|
|
})
|
|
);
|
|
});
|
|
});
|