Files
obsidian-livesync/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.unit.spec.ts
T

147 lines
5.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { POSTPONED, ConflictResolveModal } from "./ConflictResolveModal.ts";
import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({
App: class App {},
Modal: class Modal {
createdButtons: string[] = [];
private createElement(): Record<string, unknown> {
const element: Record<string, unknown> = {
addClass: vi.fn(),
addEventListener: vi.fn(),
appendText: vi.fn(),
classList: {
add: vi.fn(),
remove: vi.fn(),
},
empty: vi.fn(),
querySelector: vi.fn(() => null),
querySelectorAll: vi.fn(() => []),
scrollIntoView: vi.fn(),
setText: vi.fn(),
};
element.createDiv = vi.fn(() => this.createElement());
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
if (
_tag === "button" &&
typeof _options === "object" &&
_options !== null &&
"text" in _options
) {
this.createdButtons.push(String((_options as { text: unknown }).text));
}
const child = this.createElement();
callback?.(child);
return child;
});
element.createSpan = vi.fn(() => this.createElement());
return element;
}
contentEl = this.createElement();
titleEl = {
setText: vi.fn(),
};
close() {
(this as { onClose?: () => void }).onClose?.();
}
},
}));
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: [],
};
describe("ConflictResolveModal result lifecycle", () => {
it("returns a response which closes the dialogue before the caller begins waiting", async () => {
const modal = new ConflictResolveModal({} as never, "early-response.md" as FilePathWithPrefix, conflict);
modal.sendResponse(POSTPONED);
const result = await Promise.race([
modal.waitForResult(),
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 250)),
]);
expect(result).toBe(POSTPONED);
});
it("cancels the previous same-path dialogue without cancelling the replacement", async () => {
const filename = "same-path.md" as FilePathWithPrefix;
const previous = new ConflictResolveModal({} as never, filename, conflict);
const replacement = new ConflictResolveModal({} as never, filename, conflict);
previous.onOpen();
replacement.onOpen();
const previousResult = await Promise.race([
previous.waitForResult(),
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 250)),
]);
const replacementState = await Promise.race([
replacement.waitForResult(),
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
]);
previous.sendResponse(CANCELLED);
replacement.sendResponse(CANCELLED);
expect(previousResult).toBe(CANCELLED);
expect(replacementState).toBe("still-open");
});
it("renders a read-only comparison with no resolution actions", () => {
const ReadOnlyModal = ConflictResolveModal as unknown as new (
...args: unknown[]
) => ConflictResolveModal & { createdButtons: string[] };
const modal = new ReadOnlyModal(
{},
"repair-preview.md",
conflict,
false,
undefined,
{
readOnly: true,
title: "Vault and database revision",
localName: "Vault file",
remoteName: "Database revision",
}
);
modal.onOpen();
expect(modal.createdButtons).toContain("Close");
expect(modal.createdButtons).not.toContain("Use Vault file");
expect(modal.createdButtons).not.toContain("Use Database revision");
expect(modal.createdButtons).not.toContain("Concat both");
expect(modal.createdButtons).not.toContain("Not now");
modal.close();
});
it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => {
const filename = "repair-alongside-conflict.md" as FilePathWithPrefix;
const previous = new ConflictResolveModal({} as never, filename, conflict);
const ReadOnlyModal = ConflictResolveModal as unknown as new (
...args: unknown[]
) => ConflictResolveModal;
const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
readOnly: true,
});
previous.onOpen();
comparison.onOpen();
const previousState = await Promise.race([
previous.waitForResult(),
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
]);
previous.sendResponse(CANCELLED);
comparison.close();
expect(previousState).toBe("still-open");
});
});