Files
obsidian-livesync/src/modules/services/ObsidianConfirm.unit.spec.ts
T

308 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
confirmAction: vi.fn(),
pickOne: vi.fn(),
promptPassword: vi.fn(),
promptText: vi.fn(),
legacyAskSelectString: vi.fn(),
legacyAskString: vi.fn(),
legacyAskYesNo: vi.fn(),
legacyConfirm: vi.fn(),
legacyWideConfirm: vi.fn(),
}));
vi.mock("@vrtmrz/obsidian-plugin-kit", () => ({
confirmAction: mocks.confirmAction,
pickOne: mocks.pickOne,
promptPassword: mocks.promptPassword,
promptText: mocks.promptText,
}));
vi.mock("@/modules/coreObsidian/UILib/dialogs", () => ({
askSelectString: mocks.legacyAskSelectString,
askString: mocks.legacyAskString,
askYesNo: mocks.legacyAskYesNo,
confirmWithMessage: mocks.legacyConfirm,
confirmWithMessageWithWideButton: mocks.legacyWideConfirm,
}));
vi.mock("@/deps", () => ({
Notice: class {},
}));
import { EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { ObsidianConfirm } from "./ObsidianConfirm";
import type { ObsidianServiceContext } from "./ObsidianServiceContext";
function createConfirm() {
const app = { id: "app" };
const plugin = { app };
const events = createLiveSyncEventHub();
const notices = {
show: vi.fn(),
hide: vi.fn(),
};
const context = { app, plugin, events, notices } as unknown as ObsidianServiceContext;
return { confirm: new ObsidianConfirm(context), events, app, plugin, notices };
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("ObsidianConfirm Fancy Kit adapter", () => {
it("uses owner-bound Kit prompts and preserves cancellation and empty input", async () => {
const { confirm, app } = createConfirm();
mocks.promptText.mockResolvedValueOnce(null);
mocks.promptPassword.mockResolvedValueOnce("");
await expect(confirm.askString("Name", "Device name", "New Remote")).resolves.toBe(false);
await expect(confirm.askString("Secret", "Passphrase", "Enter it", true)).resolves.toBe("");
expect(mocks.promptText).toHaveBeenCalledWith(
app,
{
title: "Name",
label: "Device name",
placeholder: "New Remote",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.promptPassword).toHaveBeenCalledWith(
app,
{
title: "Secret",
label: "Passphrase",
placeholder: "Enter it",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyAskString).not.toHaveBeenCalled();
});
it("uses Kit for untimed yes/no and typed selection while preserving dismissed results", async () => {
const { confirm, app } = createConfirm();
mocks.confirmAction.mockResolvedValueOnce("yes");
mocks.pickOne.mockResolvedValueOnce("Beta").mockResolvedValueOnce(null);
await expect(confirm.askYesNo("Continue?")).resolves.toBe("yes");
await expect(confirm.askSelectString("Target", ["Alpha", "Beta"])).resolves.toBe("Beta");
await expect(confirm.askSelectString("Target", ["Alpha"])).resolves.toBe("");
expect(mocks.confirmAction).toHaveBeenCalledWith(
app,
expect.objectContaining({
message: "Continue?",
actions: ["yes", "no"],
actionLayout: "vertical",
defaultAction: "no",
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.pickOne).toHaveBeenCalledWith(
app,
expect.objectContaining({
items: ["Alpha", "Beta"],
getText: expect.any(Function),
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyAskYesNo).not.toHaveBeenCalled();
expect(mocks.legacyAskSelectString).not.toHaveBeenCalled();
});
it("keeps untimed and countdown action dialogues vertically stacked", async () => {
const { confirm, app, plugin } = createConfirm();
mocks.confirmAction.mockResolvedValueOnce("Apply").mockResolvedValueOnce("Yes");
mocks.legacyWideConfirm.mockResolvedValueOnce("Cancel").mockResolvedValueOnce("No");
await expect(confirm.confirmWithMessage("Review", "**Apply?**", ["Apply", "Cancel"], "Cancel")).resolves.toBe(
"Apply"
);
await expect(confirm.askYesNoDialog("Continue?", { title: "Question", defaultOption: "Yes" })).resolves.toBe(
"yes"
);
await expect(confirm.confirmWithMessage("Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30)).resolves.toBe(
"Cancel"
);
await expect(confirm.askYesNoDialog("Timed?", { defaultOption: "No", timeout: 10 })).resolves.toBe("no");
expect(mocks.confirmAction).toHaveBeenNthCalledWith(
1,
app,
{
title: "Review",
message: "**Apply?**",
actions: ["Apply", "Cancel"],
actionLayout: "vertical",
defaultAction: "Cancel",
sourcePath: "/",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.confirmAction).toHaveBeenNthCalledWith(
2,
app,
expect.objectContaining({
title: "Question",
message: "Continue?",
actions: ["Yes", "No"],
actionLayout: "vertical",
defaultAction: "Yes",
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyWideConfirm).toHaveBeenNthCalledWith(
1,
plugin,
"Timed",
"Wait",
["Apply", "Cancel"],
"Cancel",
30
);
expect(mocks.legacyWideConfirm).toHaveBeenNthCalledWith(
2,
plugin,
expect.any(String),
"Timed?",
expect.any(Array),
expect.any(String),
10
);
expect(mocks.legacyConfirm).not.toHaveBeenCalled();
});
it("uses Kit for untimed multi-action selection and keeps timed wide actions on the countdown dialogue", async () => {
const { confirm, app, plugin } = createConfirm();
const actions = ["Apply now", "Review later"] as const;
mocks.confirmAction.mockResolvedValueOnce("Review later");
mocks.legacyWideConfirm.mockResolvedValueOnce("Apply now");
await expect(
confirm.askSelectStringDialogue("Choose the next step", actions, {
title: "Next step",
defaultAction: "Review later",
})
).resolves.toBe("Review later");
await expect(
confirm.askSelectStringDialogue("Choose before the timer expires", actions, {
title: "Timed step",
defaultAction: "Apply now",
timeout: 15,
})
).resolves.toBe("Apply now");
expect(mocks.confirmAction).toHaveBeenCalledWith(
app,
{
title: "Next step",
message: "Choose the next step",
actions,
actionLayout: "vertical",
defaultAction: "Review later",
sourcePath: "/",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
"Timed step",
"Choose before the timer expires",
actions,
"Apply now",
15
);
});
it("refers P2P connection approvals to the current P2P Status pane", async () => {
const { confirm, plugin } = createConfirm();
const actions = ["Accept", "Ignore"] as const;
mocks.legacyWideConfirm.mockResolvedValueOnce("Ignore");
await confirm.askSelectStringDialogue(
"You can revoke your decision from the Peer-to-Peer Replicator Pane.",
actions,
{
title: "P2P Connection Request",
defaultAction: "Ignore",
timeout: 30,
}
);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
"P2P Connection Request",
"You can revoke your decision from the P2P Status pane.",
actions,
"Ignore",
30
);
});
it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => {
const { confirm, events } = createConfirm();
let observedSignal: AbortSignal | undefined;
mocks.confirmAction.mockImplementation(
(_app, _options, lifecycle: { signal: AbortSignal }) =>
new Promise<null>((resolve) => {
observedSignal = lifecycle.signal;
lifecycle.signal.addEventListener("abort", () => resolve(null), { once: true });
})
);
const result = confirm.confirmWithMessage("Review", "Message", ["OK"], "OK");
expect(observedSignal?.aborted).toBe(false);
events.emitEvent(EVENT_PLUGIN_UNLOADED);
await expect(result).resolves.toBe(false);
expect(observedSignal?.aborted).toBe(true);
});
it("routes popup display and expiry through the context-owned keyed Notice manager", async () => {
vi.useFakeTimers();
const popupKey = "popup-remote-size-exceeded";
const fragment = {} as DocumentFragment;
const anchor = { addEventListener: vi.fn() } as unknown as HTMLAnchorElement;
const span = {
appendText: vi.fn(),
appendChild: vi.fn(),
createEl: vi.fn((_tag, _options, callback: (element: HTMLAnchorElement) => void) => {
callback(anchor);
return anchor;
}),
};
vi.stubGlobal("createFragment", (callback: (document: unknown) => void) => {
callback({
createSpan: (_options: unknown, build: (element: typeof span) => void) => build(span),
});
return fragment;
});
const { confirm, notices } = createConfirm();
try {
confirm.askInPopup("remote-size-exceeded", "Review {HERE} details", vi.fn(), 20_000);
await vi.advanceTimersByTimeAsync(1_000);
expect(notices.show).toHaveBeenCalledWith(popupKey, fragment, { durationMs: 20_000 });
} finally {
await vi.runAllTimersAsync();
vi.useRealTimers();
vi.unstubAllGlobals();
}
});
it("closes an owned keyed Notice when the plug-in unload event is emitted", () => {
const { confirm, events, notices } = createConfirm();
const popupKey = "popup-remote-size-exceeded";
(confirm as unknown as { popupKeys: Set<string> }).popupKeys.add(popupKey);
events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(notices.hide).toHaveBeenCalledWith(popupKey);
});
});