mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-25 22:12:59 +00:00
Improve postponed conflict resolution handling
Keep unresolved conflicts visible after Not now, reopen them only on explicit requests, and clear stale warning and dialogue state when a replicated resolution arrives. Add typed provisional English messages, revision-tree regressions, specifications, and a focused Real Obsidian E2E.
This commit is contained in:
@@ -16,16 +16,10 @@ import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/s
|
||||
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { eventHub } from "@/common/events.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
"conflict-cancelled": FilePathWithPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
export class ModuleConflictResolver extends AbstractModule {
|
||||
private async _resolveConflictByDeletingRev(
|
||||
path: FilePathWithPrefix,
|
||||
@@ -41,7 +35,7 @@ export class ModuleConflictResolver extends AbstractModule {
|
||||
);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
eventHub.emitEvent("conflict-cancelled", path);
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
this._log(
|
||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||
LOG_LEVEL_INFO
|
||||
@@ -131,11 +125,12 @@ export class ModuleConflictResolver extends AbstractModule {
|
||||
// const filename = filenames[0];
|
||||
return await serialized(`conflict-resolve:${filename}`, async () => {
|
||||
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
|
||||
if (
|
||||
conflictCheckResult === MISSING_OR_ERROR ||
|
||||
conflictCheckResult === NOT_CONFLICTED ||
|
||||
conflictCheckResult === CANCELLED
|
||||
) {
|
||||
if (conflictCheckResult === NOT_CONFLICTED) {
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
|
||||
// nothing to do.
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
@@ -161,7 +156,7 @@ export class ModuleConflictResolver extends AbstractModule {
|
||||
}
|
||||
}
|
||||
this._log("[conflict] Manual merge required!");
|
||||
eventHub.emitEvent("conflict-cancelled", filename);
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
await this.services.conflict.resolveByUserInteraction(filename, conflictCheckResult);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
import { ModuleConflictResolver } from "./ModuleConflictResolver";
|
||||
|
||||
function createModule(files: FilePathWithPrefix[] = []) {
|
||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||
const tryAutoMerge = vi.fn();
|
||||
const core = {
|
||||
_services: {
|
||||
API: {
|
||||
@@ -23,6 +26,7 @@ function createModule(files: FilePathWithPrefix[] = []) {
|
||||
},
|
||||
conflict: {
|
||||
resolveByNewest: vi.fn(async () => true),
|
||||
resolveByDeletingRevision,
|
||||
},
|
||||
},
|
||||
settings: DEFAULT_SETTINGS,
|
||||
@@ -32,6 +36,10 @@ function createModule(files: FilePathWithPrefix[] = []) {
|
||||
},
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => []),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: {
|
||||
tryAutoMerge,
|
||||
},
|
||||
storageAccess: {
|
||||
getFileNames: vi.fn(async () => files),
|
||||
@@ -41,7 +49,7 @@ function createModule(files: FilePathWithPrefix[] = []) {
|
||||
|
||||
const module = new ModuleConflictResolver(core);
|
||||
module._log = vi.fn();
|
||||
return { module };
|
||||
return { module, resolveByDeletingRevision, tryAutoMerge };
|
||||
}
|
||||
|
||||
describe("ModuleConflictResolver bulk newest resolution", () => {
|
||||
@@ -116,3 +124,73 @@ describe("ModuleConflictResolver bulk newest resolution", () => {
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { type Editor, type MarkdownFileInfo, type MarkdownView } from "@/deps.ts";
|
||||
import { addIcon } from "@/deps.ts";
|
||||
import { type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { $msg } from "@/common/translation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
@@ -22,16 +20,6 @@ export class ModuleObsidianMenu extends AbstractModule {
|
||||
await this.services.replication.replicate(true);
|
||||
}).addClass("livesync-ribbon-replicate");
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-checkdoc-conflicted",
|
||||
name: "Resolve if conflicted.",
|
||||
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
void this.services.conflict.queueCheckForIfOpen(file.path as FilePathWithPrefix);
|
||||
},
|
||||
});
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ import { App, Modal } from "@/deps.ts";
|
||||
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
|
||||
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { eventHub } from "@/common/events.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import { globalSlipBoard } from "@vrtmrz/livesync-commonlib/compat/bureau/bureau";
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
|
||||
declare global {
|
||||
interface Slips {
|
||||
@@ -100,7 +102,7 @@ export class ConflictResolveModal extends Modal {
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
this.offEvent = eventHub.onEvent("conflict-cancelled", (path) => {
|
||||
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
@@ -169,7 +171,7 @@ export class ConflictResolveModal extends Modal {
|
||||
}
|
||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED));
|
||||
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
|
||||
});
|
||||
if (diffLength > 100 * 1024) {
|
||||
this.diffView.empty();
|
||||
|
||||
@@ -9,15 +9,67 @@ import {
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConflictResolveModal } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { ConflictResolveModal, POSTPONED } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
|
||||
import { displayRev } from "@/common/utils.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
|
||||
import { $msg } from "@/common/translation.ts";
|
||||
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
|
||||
|
||||
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
|
||||
|
||||
private async isConflicted(filename: FilePathWithPrefix): Promise<boolean | undefined> {
|
||||
try {
|
||||
return (await this.core.databaseFileAccess.getConflictedRevs(filename)).length > 0;
|
||||
} catch (error) {
|
||||
this._log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(error, LOG_LEVEL_VERBOSE);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async getActiveConflictMessages(): Promise<string[]> {
|
||||
const filename = this.services.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const conflicted = await this.isConflicted(filename);
|
||||
if (conflicted === false) {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
}
|
||||
if (conflicted === true || this.postponedConflictEpisodes.has(filename)) {
|
||||
return [$msg("This file has unresolved conflicts.")];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private async refreshConflictState(filename: FilePathWithPrefix): Promise<void> {
|
||||
if ((await this.isConflicted(filename)) === false) {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
}
|
||||
|
||||
private async requestConflictResolution(filename: FilePathWithPrefix): Promise<void> {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
await this.services.conflict.ensureAllProcessed();
|
||||
}
|
||||
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-checkdoc-conflicted",
|
||||
name: "Resolve if conflicted.",
|
||||
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
void this.requestConflictResolution(file.path as FilePathWithPrefix);
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-conflictcheck",
|
||||
name: "Pick a file to resolve conflict",
|
||||
@@ -38,10 +90,21 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
async _anyResolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise<boolean> {
|
||||
// UI for resolving conflicts should one-by-one.
|
||||
return await serialized(`conflict-resolve-ui`, async () => {
|
||||
if (this.postponedConflictEpisodes.has(filename)) {
|
||||
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
return false;
|
||||
}
|
||||
this._log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
|
||||
const dialog = new ConflictResolveModal(this.app, filename, conflictCheckResult);
|
||||
dialog.open();
|
||||
const selected = await dialog.waitForResult();
|
||||
if (selected === POSTPONED) {
|
||||
this.postponedConflictEpisodes.add(filename);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (selected === CANCELLED) {
|
||||
// Cancelled by UI, or another conflict.
|
||||
this._log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
|
||||
@@ -126,8 +189,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (target) {
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await this.services.conflict.queueCheckFor(targetItem.path);
|
||||
await this.services.conflict.ensureAllProcessed();
|
||||
await this.requestConflictResolution(targetItem.path);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -172,6 +234,10 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(this.getActiveConflictMessages.bind(this));
|
||||
services.conflict.resolveByUserInteraction.addHandler(this._anyResolveConflictByUI.bind(this));
|
||||
eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => {
|
||||
fireAndForget(() => this.refreshConflictState(filename));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CANCELLED,
|
||||
DEFAULT_SETTINGS,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const modalState = vi.hoisted(() => ({
|
||||
constructed: 0,
|
||||
result: undefined as unknown,
|
||||
postponed: Symbol("postponed"),
|
||||
}));
|
||||
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
displayRev: (revision: string) => revision,
|
||||
}));
|
||||
|
||||
vi.mock("./InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
POSTPONED: modalState.postponed,
|
||||
ConflictResolveModal: class ConflictResolveModal {
|
||||
constructor() {
|
||||
modalState.constructed++;
|
||||
}
|
||||
|
||||
open() {}
|
||||
|
||||
async waitForResult() {
|
||||
return modalState.result;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { ModuleInteractiveConflictResolver } from "./ModuleInteractiveConflictResolver.ts";
|
||||
|
||||
const path = "note.md" as FilePathWithPrefix;
|
||||
const conflict: diff_result = {
|
||||
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
|
||||
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
};
|
||||
|
||||
function createModule(conflictedRevisions: string[] = ["2-right"]) {
|
||||
const handlers = {
|
||||
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: {
|
||||
addHandler: vi.fn((handler: () => Promise<string[]>) => {
|
||||
handlers.unresolvedMessages = handler;
|
||||
}),
|
||||
},
|
||||
onScanningStartupIssues: { addHandler: vi.fn() },
|
||||
onInitialise: { addHandler: vi.fn() },
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
conflict: {
|
||||
resolveByUserInteraction: { addHandler: vi.fn() },
|
||||
resolveByDeletingRevision: vi.fn(),
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
},
|
||||
replication: { replicateByEvent: vi.fn(async () => true) },
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
path: { getPath: vi.fn(), getPathWithoutPrefix: vi.fn() },
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: { ...DEFAULT_SETTINGS, syncAfterMerge: false },
|
||||
localDatabase: {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => conflictedRevisions),
|
||||
},
|
||||
};
|
||||
const plugin = { app: {} };
|
||||
const module = new ModuleInteractiveConflictResolver(plugin as never, core as never);
|
||||
module._log = vi.fn();
|
||||
return { core, handlers, module, services };
|
||||
}
|
||||
|
||||
describe("ModuleInteractiveConflictResolver postponement", () => {
|
||||
beforeEach(() => {
|
||||
modalState.constructed = 0;
|
||||
modalState.result = modalState.postponed;
|
||||
});
|
||||
|
||||
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
|
||||
const { module } = createModule();
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
|
||||
const { module } = createModule();
|
||||
modalState.result = CANCELLED;
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
|
||||
const { module, services } = createModule();
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await (module as any).requestConflictResolution(path);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(services.conflict.ensureAllProcessed).toHaveBeenCalledOnce();
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { module } = createModule(conflictedRevisions);
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
conflictedRevisions.splice(0);
|
||||
await (module as any).refreshConflictState(path);
|
||||
conflictedRevisions.push("4-later");
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("contributes the active conflict to the existing unresolved-message display", async () => {
|
||||
const { core, handlers, module, services } = createModule();
|
||||
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
expect(services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
});
|
||||
|
||||
it("removes the active warning once the conflict has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { core, handlers, module, services } = createModule(conflictedRevisions);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
conflictedRevisions.splice(0);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user