mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-01 15:02:31 +00:00
Strengthen conflict resolution regression coverage
This commit is contained in:
@@ -170,6 +170,66 @@ describe("conflict resolution serviceFeature", () => {
|
|||||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deletes the older conflict leaf when matching current content is newer", async () => {
|
||||||
|
const harness = createOperationsHarness();
|
||||||
|
const path = "same-newer-current.md" as FilePathWithPrefix;
|
||||||
|
const leftLeaf = leaf("2-left", "Same content\n", 3000);
|
||||||
|
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||||
|
harness.tryAutoMerge.mockResolvedValue({
|
||||||
|
leftRev: leftLeaf.rev,
|
||||||
|
rightRev: rightLeaf.rev,
|
||||||
|
leftLeaf,
|
||||||
|
rightLeaf,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||||
|
|
||||||
|
expect(result).toBe(AUTO_MERGED);
|
||||||
|
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-right", "same");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
description: "binary content",
|
||||||
|
path: "different.png" as FilePathWithPrefix,
|
||||||
|
resolveConflictsByNewerFile: false,
|
||||||
|
expectedSubtitle: "binary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "the newer-file policy",
|
||||||
|
path: "different.md" as FilePathWithPrefix,
|
||||||
|
resolveConflictsByNewerFile: true,
|
||||||
|
expectedSubtitle: "alwaysNewer",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"retains automatic resolution for $description",
|
||||||
|
async ({ path, resolveConflictsByNewerFile, expectedSubtitle }) => {
|
||||||
|
const harness = createOperationsHarness();
|
||||||
|
const leftLeaf = leaf("1-left", "Left content\n", 1000);
|
||||||
|
const rightLeaf = leaf("2-right", "Right content\n", 2000);
|
||||||
|
harness.tryAutoMerge.mockResolvedValue({
|
||||||
|
leftRev: leftLeaf.rev,
|
||||||
|
rightRev: rightLeaf.rev,
|
||||||
|
leftLeaf,
|
||||||
|
rightLeaf,
|
||||||
|
});
|
||||||
|
const operations = createConflictResolutionOperations({
|
||||||
|
...harness.dependencies,
|
||||||
|
currentSettings: () => ({
|
||||||
|
disableMarkdownAutoMerge: false,
|
||||||
|
resolveConflictsByNewerFile,
|
||||||
|
syncAfterMerge: false,
|
||||||
|
showMergeDialogOnlyOnActive: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await operations.checkConflictAndPerformAutoMerge(path);
|
||||||
|
|
||||||
|
expect(result).toBe(AUTO_MERGED);
|
||||||
|
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", expectedSubtitle);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it("acquires the active local database for each resolution attempt", async () => {
|
it("acquires the active local database for each resolution attempt", async () => {
|
||||||
const harness = createOperationsHarness();
|
const harness = createOperationsHarness();
|
||||||
const path = "database-reset.md" as FilePathWithPrefix;
|
const path = "database-reset.md" as FilePathWithPrefix;
|
||||||
@@ -254,6 +314,34 @@ describe("conflict resolution serviceFeature", () => {
|
|||||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a safe error and logs when reflecting the resolved revision fails", async () => {
|
||||||
|
const harness = createOperationsHarness();
|
||||||
|
const path = "failed-reflection.md" as FilePathWithPrefix;
|
||||||
|
harness.fileHandler.dbToStorage.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
|
||||||
|
|
||||||
|
expect(result).toBe(MISSING_OR_ERROR);
|
||||||
|
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
|
||||||
|
expect(harness.dependencies.log).toHaveBeenCalledWith(
|
||||||
|
`Could not write the resolved content to the storage: ${path}`,
|
||||||
|
LOG_LEVEL_NOTICE
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["plugin metadata", "ps:plugin-metadata" as FilePathWithPrefix],
|
||||||
|
["customisation metadata", "ix:customisation-metadata" as FilePathWithPrefix],
|
||||||
|
])("does not reflect %s into the Vault after deleting its revision", async (_description, path) => {
|
||||||
|
const harness = createOperationsHarness();
|
||||||
|
|
||||||
|
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
|
||||||
|
|
||||||
|
expect(result).toBe(AUTO_MERGED);
|
||||||
|
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-right");
|
||||||
|
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("rechecks a remaining manual pair after committing a sensible merge", async () => {
|
it("rechecks a remaining manual pair after committing a sensible merge", async () => {
|
||||||
const harness = createOperationsHarness();
|
const harness = createOperationsHarness();
|
||||||
const path = "three-versions.md" as FilePathWithPrefix;
|
const path = "three-versions.md" as FilePathWithPrefix;
|
||||||
@@ -333,6 +421,35 @@ describe("conflict resolution serviceFeature", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a manual merge when the active-only setting matches the current file", async () => {
|
||||||
|
const harness = createOperationsHarness();
|
||||||
|
const path = "active.md" as FilePathWithPrefix;
|
||||||
|
const activePath = "active.md" as FilePath;
|
||||||
|
harness.tryAutoMerge.mockResolvedValue({
|
||||||
|
leftRev: "2-left",
|
||||||
|
rightRev: "2-right",
|
||||||
|
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||||
|
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||||
|
});
|
||||||
|
const operations = createConflictResolutionOperations({
|
||||||
|
...harness.dependencies,
|
||||||
|
currentSettings: () => ({
|
||||||
|
disableMarkdownAutoMerge: false,
|
||||||
|
resolveConflictsByNewerFile: false,
|
||||||
|
syncAfterMerge: false,
|
||||||
|
showMergeDialogOnlyOnActive: true,
|
||||||
|
}),
|
||||||
|
vault: { getActiveFilePath: () => activePath },
|
||||||
|
});
|
||||||
|
|
||||||
|
await operations.resolve(path);
|
||||||
|
|
||||||
|
expect(harness.resolveByUserInteraction).toHaveBeenCalledWith(
|
||||||
|
path,
|
||||||
|
expect.objectContaining({ left: expect.anything(), right: expect.anything() })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("cancels an active same-file dialogue before serialising a repeated resolution", async () => {
|
it("cancels an active same-file dialogue before serialising a repeated resolution", async () => {
|
||||||
const harness = createOperationsHarness();
|
const harness = createOperationsHarness();
|
||||||
const path = "repeated.md" as FilePathWithPrefix;
|
const path = "repeated.md" as FilePathWithPrefix;
|
||||||
@@ -472,6 +589,21 @@ describe("conflict resolution serviceFeature", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["the active-file gate is disabled", { checkConflictOnlyOnOpen: false }, "other.md" as FilePathWithPrefix],
|
||||||
|
["the requested path is active", { checkConflictOnlyOnOpen: true }, "active.md" as FilePathWithPrefix],
|
||||||
|
])("reaches the resolver when %s", async (_description, settings, activeFile) => {
|
||||||
|
const path = "active.md" as FilePathWithPrefix;
|
||||||
|
const harness = createHarness({ settings, activeFile });
|
||||||
|
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||||
|
|
||||||
|
await harness.conflict.queueCheckForIfOpen(path);
|
||||||
|
await harness.conflict.ensureAllProcessed();
|
||||||
|
|
||||||
|
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
|
||||||
|
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
|
||||||
|
});
|
||||||
|
|
||||||
it("limits concurrent conflict checks and reports queued and active work", async () => {
|
it("limits concurrent conflict checks and reports queued and active work", async () => {
|
||||||
const paths = Array.from({ length: 11 }, (_, index) => `concurrent-${index}.md` as FilePathWithPrefix);
|
const paths = Array.from({ length: 11 }, (_, index) => `concurrent-${index}.md` as FilePathWithPrefix);
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
@@ -632,6 +764,27 @@ describe("conflict resolution serviceFeature", () => {
|
|||||||
expect(MISSING_OR_ERROR).toBeDefined();
|
expect(MISSING_OR_ERROR).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves revisions and returns a safe error when the current leaf is unreadable", async () => {
|
||||||
|
const harness = createOperationsHarness();
|
||||||
|
const path = "unreadable-current.md" as FilePathWithPrefix;
|
||||||
|
harness.tryAutoMerge.mockResolvedValue({
|
||||||
|
leftRev: "3-current",
|
||||||
|
rightRev: "2-conflict",
|
||||||
|
leftLeaf: false,
|
||||||
|
rightLeaf: leaf("2-conflict", "Conflict body\n", 2),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||||
|
|
||||||
|
expect(result).toBe(MISSING_OR_ERROR);
|
||||||
|
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||||
|
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||||
|
expect(harness.dependencies.log).toHaveBeenCalledWith(
|
||||||
|
`could not get current revisions:${path}`,
|
||||||
|
LOG_LEVEL_NOTICE
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves an identical pair, emits cancellation, and rechecks after merging", async () => {
|
it("resolves an identical pair, emits cancellation, and rechecks after merging", async () => {
|
||||||
const path = "independently-created.md" as FilePathWithPrefix;
|
const path = "independently-created.md" as FilePathWithPrefix;
|
||||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||||
@@ -704,4 +857,27 @@ describe("conflict resolution serviceFeature", () => {
|
|||||||
LOG_LEVEL_NOTICE
|
LOG_LEVEL_NOTICE
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ description: "no conflicts remain", conflicts: [] as string[], expectedDeletes: [] as string[] },
|
||||||
|
{
|
||||||
|
description: "conflict metadata is unreadable",
|
||||||
|
conflicts: ["2-unreadable"],
|
||||||
|
expectedDeletes: ["2-unreadable"],
|
||||||
|
},
|
||||||
|
])("handles the case where $description while resolving by newest", async ({ conflicts, expectedDeletes }) => {
|
||||||
|
const harness = createOperationsHarness();
|
||||||
|
const path = "newest-policy.md" as FilePathWithPrefix;
|
||||||
|
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(
|
||||||
|
async (_filename: FilePathWithPrefix, revision?: string) =>
|
||||||
|
revision === undefined ? metadata(path, "3-current", 1000) : false
|
||||||
|
);
|
||||||
|
harness.databaseFileAccess.getConflictedRevs.mockResolvedValueOnce(conflicts).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await expect(harness.operations.resolveByNewest(path)).resolves.toBe(true);
|
||||||
|
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledTimes(expectedDeletes.length);
|
||||||
|
for (const [index, revision] of expectedDeletes.entries()) {
|
||||||
|
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(index + 1, path, revision);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+226
-11
@@ -9,7 +9,7 @@ import {
|
|||||||
type FilePathWithPrefix,
|
type FilePathWithPrefix,
|
||||||
type diff_result,
|
type diff_result,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@/common/events";
|
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, EVENT_PLUGIN_UNLOADED } from "@/common/events";
|
||||||
import {
|
import {
|
||||||
createInteractiveConflictResolutionOperations,
|
createInteractiveConflictResolutionOperations,
|
||||||
type InteractiveConflictResolutionOperationsDependencies,
|
type InteractiveConflictResolutionOperationsDependencies,
|
||||||
@@ -37,12 +37,14 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
|||||||
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
|
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
|
||||||
const findAllDocs = vi.fn(() => documents([]));
|
const findAllDocs = vi.fn(() => documents([]));
|
||||||
const askSelectString = vi.fn(async (): Promise<string> => "");
|
const askSelectString = vi.fn(async (): Promise<string> => "");
|
||||||
|
const askInPopup = vi.fn();
|
||||||
const queueCheckFor = vi.fn(async () => undefined);
|
const queueCheckFor = vi.fn(async () => undefined);
|
||||||
const ensureAllProcessed = vi.fn(async () => true);
|
const ensureAllProcessed = vi.fn(async () => true);
|
||||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||||
const getConflictedRevs = vi.fn(async () => conflictedRevisions);
|
const getConflictedRevs = vi.fn(async () => conflictedRevisions);
|
||||||
const storeContent = vi.fn(async () => true);
|
const storeContent = vi.fn(async () => true);
|
||||||
const isSuspended = vi.fn(() => false);
|
const isSuspended = vi.fn(() => false);
|
||||||
|
const getActiveFilePath = vi.fn((): FilePathWithPrefix | undefined => path);
|
||||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
||||||
const currentSettings = vi.fn(() => ({ syncAfterMerge: false }));
|
const currentSettings = vi.fn(() => ({ syncAfterMerge: false }));
|
||||||
const log = vi.fn();
|
const log = vi.fn();
|
||||||
@@ -55,10 +57,10 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
|||||||
localDatabase: () => ({ getDBEntry, findAllDocs }),
|
localDatabase: () => ({ getDBEntry, findAllDocs }),
|
||||||
confirm: {
|
confirm: {
|
||||||
askSelectString,
|
askSelectString,
|
||||||
askInPopup: vi.fn(),
|
askInPopup,
|
||||||
},
|
},
|
||||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
vault: { getActiveFilePath },
|
||||||
appLifecycle: { isSuspended },
|
appLifecycle: { isSuspended },
|
||||||
conflict: { queueCheckFor, ensureAllProcessed, resolveByDeletingRevision },
|
conflict: { queueCheckFor, ensureAllProcessed, resolveByDeletingRevision },
|
||||||
replication: {
|
replication: {
|
||||||
@@ -76,6 +78,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
|||||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||||
return {
|
return {
|
||||||
askSelectString,
|
askSelectString,
|
||||||
|
askInPopup,
|
||||||
constructed,
|
constructed,
|
||||||
context,
|
context,
|
||||||
dialogueResult: {
|
dialogueResult: {
|
||||||
@@ -88,6 +91,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
|||||||
},
|
},
|
||||||
findAllDocs,
|
findAllDocs,
|
||||||
getDBEntry,
|
getDBEntry,
|
||||||
|
getActiveFilePath,
|
||||||
getConflictedRevs,
|
getConflictedRevs,
|
||||||
ensureAllProcessed,
|
ensureAllProcessed,
|
||||||
currentSettings,
|
currentSettings,
|
||||||
@@ -194,6 +198,22 @@ describe("interactive conflict resolution operations", () => {
|
|||||||
await Promise.all([first, superseded, replacement]);
|
await Promise.all([first, superseded, replacement]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a same-file replacement request when its cancellation refresh sees the active dialogue", async () => {
|
||||||
|
const dialogues: ControlledDialogue[] = [];
|
||||||
|
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||||
|
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||||
|
|
||||||
|
const first = resolveByUserInteraction(path, conflict);
|
||||||
|
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
|
||||||
|
const replacement = resolveByUserInteraction(path, conflict);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(dialogues).toHaveLength(2));
|
||||||
|
expect(dialogues[1]?.filename).toBe(path);
|
||||||
|
|
||||||
|
dialogues[1]?.finish(CANCELLED);
|
||||||
|
await Promise.all([first, replacement]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a different file waiting until the active dialogue finishes", async () => {
|
it("keeps a different file waiting until the active dialogue finishes", async () => {
|
||||||
const fixture = createDialogueConcurrencyHarness();
|
const fixture = createDialogueConcurrencyHarness();
|
||||||
const otherPath = "other.md" as FilePathWithPrefix;
|
const otherPath = "other.md" as FilePathWithPrefix;
|
||||||
@@ -259,12 +279,55 @@ describe("interactive conflict resolution operations", () => {
|
|||||||
expect(constructed.value).toBe(2);
|
expect(constructed.value).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ label: "cannot be read", result: false as const, refreshes: false },
|
||||||
|
{ label: "has no conflicts", result: { _rev: "2-left", _conflicts: [] as string[] }, refreshes: true },
|
||||||
|
])("exits safely when the live database $label after a user selection", async ({ result, refreshes }) => {
|
||||||
|
const fixture = createOperations();
|
||||||
|
fixture.dialogueResult.value = "2-right";
|
||||||
|
fixture.getDBEntry.mockResolvedValue(result);
|
||||||
|
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
|
||||||
|
|
||||||
|
await expect(fixture.operations.resolveByUserInteraction(path, conflict)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||||
|
if (refreshes) {
|
||||||
|
expect(fixture.getConflictedRevs).toHaveBeenCalledWith(path);
|
||||||
|
expect(emitEvent).toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
|
||||||
|
} else {
|
||||||
|
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
|
||||||
|
expect(emitEvent).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("contributes the active conflict to the existing unresolved-message display", async () => {
|
it("contributes the active conflict to the existing unresolved-message display", async () => {
|
||||||
const { operations } = createOperations();
|
const { operations } = createOperations();
|
||||||
|
|
||||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns no unresolved message when there is no active file", async () => {
|
||||||
|
const { getActiveFilePath, getConflictedRevs, operations } = createOperations();
|
||||||
|
getActiveFilePath.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||||
|
|
||||||
|
expect(getConflictedRevs).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a failed conflict inspection and returns no unresolved messages", async () => {
|
||||||
|
const fixture = createOperations();
|
||||||
|
const error = new Error("database unavailable");
|
||||||
|
fixture.getConflictedRevs.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||||
|
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith("Could not inspect the conflict state of note.md", expect.anything());
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(error, expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
it("removes the active warning once the conflict has resolved", async () => {
|
it("removes the active warning once the conflict has resolved", async () => {
|
||||||
const conflictedRevisions = ["2-right"];
|
const conflictedRevisions = ["2-right"];
|
||||||
const { operations } = createOperations(conflictedRevisions);
|
const { operations } = createOperations(conflictedRevisions);
|
||||||
@@ -312,19 +375,47 @@ describe("interactive conflict resolution operations", () => {
|
|||||||
expect(restartedSession.constructed.value).toBe(1);
|
expect(restartedSession.constructed.value).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes the compared right leaf when concatenating a deterministically selected pair", async () => {
|
it("stores the exact concatenated diff before deleting a deterministically selected pair", async () => {
|
||||||
const fixture = createOperations(["2-unrelated", "2-right"]);
|
const fixture = createOperations(["2-unrelated", "2-right"]);
|
||||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||||
fixture.getDBEntry.mockResolvedValue({
|
fixture.getDBEntry.mockResolvedValue({
|
||||||
_rev: "2-left",
|
_rev: "2-left",
|
||||||
_conflicts: ["2-unrelated", "2-right"],
|
_conflicts: ["2-unrelated", "2-right"],
|
||||||
});
|
});
|
||||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
await fixture.operations.resolveByUserInteraction(path, {
|
||||||
|
...conflict,
|
||||||
|
diff: [
|
||||||
|
[0, "left"],
|
||||||
|
[1, "\n"],
|
||||||
|
[1, "right"],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
expect(fixture.storeContent).toHaveBeenCalledWith(path, "");
|
expect(fixture.storeContent).toHaveBeenCalledWith(path, "left\nright");
|
||||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not replicate or requeue when concatenated revision deletion fails", async () => {
|
||||||
|
const fixture = createOperations();
|
||||||
|
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||||
|
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||||
|
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||||
|
fixture.resolveByDeletingRevision.mockResolvedValue(MISSING_OR_ERROR);
|
||||||
|
|
||||||
|
await fixture.operations.resolveByUserInteraction(path, {
|
||||||
|
...conflict,
|
||||||
|
diff: [
|
||||||
|
[0, "left"],
|
||||||
|
[1, "\nright"],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fixture.storeContent).toHaveBeenCalledWith(path, "left\nright");
|
||||||
|
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||||
|
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
|
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
|
||||||
const fixture = createOperations(["2-other"]);
|
const fixture = createOperations(["2-other"]);
|
||||||
fixture.dialogueResult.value = "2-right";
|
fixture.dialogueResult.value = "2-right";
|
||||||
@@ -364,6 +455,22 @@ describe("interactive conflict resolution operations", () => {
|
|||||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects an unexpected dialogue result without changing revisions", async () => {
|
||||||
|
const fixture = createOperations();
|
||||||
|
fixture.dialogueResult.value = "unexpected-result";
|
||||||
|
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||||
|
|
||||||
|
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||||
|
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(
|
||||||
|
`Merge: Something went wrong: ${path}, (unexpected-result)`,
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("replicates and requeues after a selected revision is resolved", async () => {
|
it("replicates and requeues after a selected revision is resolved", async () => {
|
||||||
const fixture = createOperations();
|
const fixture = createOperations();
|
||||||
fixture.dialogueResult.value = "2-right";
|
fixture.dialogueResult.value = "2-right";
|
||||||
@@ -424,21 +531,82 @@ describe("interactive conflict resolution operations", () => {
|
|||||||
expect(fixture.askSelectString).not.toHaveBeenCalled();
|
expect(fixture.askSelectString).not.toHaveBeenCalled();
|
||||||
expect(fixture.log).toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
|
expect(fixture.log).toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sorts multiple picker candidates newest first and returns safely on cancellation", async () => {
|
||||||
|
const fixture = createOperations();
|
||||||
|
fixture.findAllDocs.mockReturnValue(
|
||||||
|
documents([
|
||||||
|
{ _id: "ordinary-id", path: "ordinary.md", mtime: 40 },
|
||||||
|
{ _id: "old-id", path: "old.md", _conflicts: ["2-old"], mtime: 10 },
|
||||||
|
{ _id: "new-id", path: "new.md", _conflicts: ["2-new"], mtime: 30 },
|
||||||
|
{ _id: "middle-id", path: "middle.md", _conflicts: ["2-middle"], mtime: 20 },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
fixture.askSelectString.mockResolvedValue("");
|
||||||
|
|
||||||
|
await expect(fixture.operations.pickFileForResolve()).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(fixture.askSelectString).toHaveBeenCalledWith("File to resolve conflict", [
|
||||||
|
"new.md",
|
||||||
|
"middle.md",
|
||||||
|
"old.md",
|
||||||
|
]);
|
||||||
|
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.ensureAllProcessed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports conflicted documents and wires the startup popup action", async () => {
|
||||||
|
const fixture = createOperations();
|
||||||
|
fixture.findAllDocs.mockImplementation(() =>
|
||||||
|
documents([
|
||||||
|
{ _id: "ordinary-id", path: "ordinary.md", mtime: 30 },
|
||||||
|
{ _id: "first-id", path: "first.md", _conflicts: ["2-first"], mtime: 10 },
|
||||||
|
{ _id: "second-id", path: "second.md", _conflicts: ["2-second"], mtime: 20 },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fixture.operations.scanStartupIssues()).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fixture.askInPopup).toHaveBeenCalledWith(
|
||||||
|
"conflicting-detected-on-safety",
|
||||||
|
expect.stringContaining("Some files have been left conflicted!"),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith("Conflicted: first.md");
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith("Conflicted: second.md");
|
||||||
|
expect(fixture.log).not.toHaveBeenCalledWith("Conflicted: ordinary.md");
|
||||||
|
|
||||||
|
const popupAction = fixture.askInPopup.mock.calls[0]?.[2] as (anchor: HTMLAnchorElement) => void;
|
||||||
|
const addEventListener = vi.fn();
|
||||||
|
const anchor = { text: "", addEventListener } as unknown as HTMLAnchorElement;
|
||||||
|
popupAction(anchor);
|
||||||
|
|
||||||
|
expect(anchor.text).toBe("HERE");
|
||||||
|
expect(addEventListener).toHaveBeenCalledWith("click", expect.any(Function));
|
||||||
|
|
||||||
|
const clickHandler = addEventListener.mock.calls[0]?.[1] as () => void;
|
||||||
|
clickHandler();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(fixture.askSelectString).toHaveBeenCalledWith("File to resolve conflict", ["second.md", "first.md"])
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createFeatureHarness(
|
function createFeatureHarness(
|
||||||
createDialogueForContext?: (
|
createDialogueForContext?: (context: ReturnType<typeof createServiceContext>) => ConflictResolveDialogueFactory
|
||||||
context: ReturnType<typeof createServiceContext>
|
|
||||||
) => ConflictResolveDialogueFactory
|
|
||||||
) {
|
) {
|
||||||
const context = createServiceContext();
|
const context = createServiceContext();
|
||||||
|
type FeatureLocalDatabase = {
|
||||||
|
getDBEntry: (...args: unknown[]) => Promise<unknown>;
|
||||||
|
findAllDocs: (...args: unknown[]) => AsyncGenerator<unknown>;
|
||||||
|
};
|
||||||
const initialLocalDatabase = {
|
const initialLocalDatabase = {
|
||||||
getDBEntry: vi.fn(async () => false),
|
getDBEntry: vi.fn(async () => false),
|
||||||
findAllDocs: vi.fn(() => documents([])),
|
findAllDocs: vi.fn(() => documents([])),
|
||||||
};
|
};
|
||||||
let activeLocalDatabase = initialLocalDatabase;
|
let activeLocalDatabase: FeatureLocalDatabase = initialLocalDatabase;
|
||||||
const getLocalDatabase = vi.fn(() => activeLocalDatabase);
|
const getLocalDatabase = vi.fn(() => activeLocalDatabase);
|
||||||
const replaceLocalDatabase = (replacement: typeof initialLocalDatabase) => {
|
const replaceLocalDatabase = (replacement: FeatureLocalDatabase) => {
|
||||||
activeLocalDatabase = replacement;
|
activeLocalDatabase = replacement;
|
||||||
};
|
};
|
||||||
const database = {} as { readonly localDatabase: ReturnType<typeof getLocalDatabase> };
|
const database = {} as { readonly localDatabase: ReturnType<typeof getLocalDatabase> };
|
||||||
@@ -605,6 +773,18 @@ describe("interactive conflict resolution feature composition", () => {
|
|||||||
await fixture.handlers.onUnload?.();
|
await fixture.handlers.onUnload?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a resolution request after unload without opening a dialogue", async () => {
|
||||||
|
const dialogues: ControlledDialogue[] = [];
|
||||||
|
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||||
|
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||||
|
|
||||||
|
await fixture.handlers.onUnload?.();
|
||||||
|
await expect(resolveByUserInteraction(path, conflict)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(dialogues).toHaveLength(0);
|
||||||
|
expect(fixture.createDialogue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("drops a waiting dialogue when its conflict is resolved elsewhere", async () => {
|
it("drops a waiting dialogue when its conflict is resolved elsewhere", async () => {
|
||||||
const dialogues: ControlledDialogue[] = [];
|
const dialogues: ControlledDialogue[] = [];
|
||||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||||
@@ -667,6 +847,41 @@ describe("interactive conflict resolution feature composition", () => {
|
|||||||
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
|
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads the host's current sync setting after a composed resolution", async () => {
|
||||||
|
const fixture = createFeatureHarness(() => () => ({
|
||||||
|
open: vi.fn(),
|
||||||
|
waitForResult: vi.fn(async (): Promise<MergeDialogResult> => "2-right"),
|
||||||
|
}));
|
||||||
|
fixture.replaceLocalDatabase({
|
||||||
|
getDBEntry: vi.fn(async () => ({ _rev: "2-left", _conflicts: ["2-right"] })),
|
||||||
|
findAllDocs: vi.fn(() => documents([])),
|
||||||
|
});
|
||||||
|
fixture.services.setting.currentSettings.mockReturnValue({
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
syncAfterMerge: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fixture.handlers.resolveByUserInteraction?.(path, conflict);
|
||||||
|
|
||||||
|
expect(fixture.services.setting.currentSettings).toHaveBeenCalledOnce();
|
||||||
|
expect(fixture.services.replication.replicateUnattendedByEvent).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the editor command has no active file", async () => {
|
||||||
|
const fixture = createFeatureHarness();
|
||||||
|
await fixture.handlers.initialise?.();
|
||||||
|
const command = fixture.services.API.addCommand.mock.calls[0]?.[0] as {
|
||||||
|
editorCallback: (editor: unknown, view: { file: null }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
command.editorCallback({}, { file: null });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fixture.services.conflict.queueCheckFor).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.services.conflict.ensureAllProcessed).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.createDialogue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("reports a failed startup scan without escaping the lifecycle handler", async () => {
|
it("reports a failed startup scan without escaping the lifecycle handler", async () => {
|
||||||
const fixture = createFeatureHarness();
|
const fixture = createFeatureHarness();
|
||||||
async function* failedDocuments() {
|
async function* failedDocuments() {
|
||||||
|
|||||||
Reference in New Issue
Block a user