Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 207 additions & 5 deletions src/engine/CaptureChoiceEngine.selection.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { App } from "obsidian";
import { Notice, TFile } from "obsidian";
import merge from "three-way-merge";
import { CaptureChoiceEngine } from "./CaptureChoiceEngine";
import type ICaptureChoice from "../types/choices/ICaptureChoice";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import { insertFileLinkToActiveView, isFolder, openFile } from "../utilityObsidian";
import {
getMarkdownFilesInFolder,
insertFileLinkToActiveView,
isFolder,
openFile,
overwriteTemplaterOnce,
templaterParseTemplate,
} from "../utilityObsidian";
import { QA_INTERNAL_CAPTURE_TARGET_FILE_PATH } from "../constants";
import { ChoiceAbortError } from "../errors/ChoiceAbortError";

const { setUseSelectionAsCaptureValueMock, setTitleMock } = vi.hoisted(() => ({
type NoticeTestClass = typeof Notice & {
instances: Array<{ message: string; timeout?: number }>;
};

const noticeClass = Notice as unknown as NoticeTestClass;

const {
inputSuggestMock,
formatFileNameMock,
setUseSelectionAsCaptureValueMock,
setTitleMock,
} = vi.hoisted(() => ({
inputSuggestMock: vi.fn(),
formatFileNameMock: vi.fn(async (name: string, _title?: string) => name),
setUseSelectionAsCaptureValueMock: vi.fn(),
setTitleMock: vi.fn(),
}));
Expand All @@ -32,8 +54,8 @@ vi.mock("../formatters/captureChoiceFormatter", () => ({
async formatContentWithFile() {
return "";
}
async formatFileName(name: string) {
return name;
async formatFileName(name: string, title?: string) {
return await formatFileNameMock(name, title);
}
getAndClearTemplatePropertyVars() {
return new Map();
Expand Down Expand Up @@ -65,7 +87,9 @@ vi.mock("three-way-merge", () => ({
}));

vi.mock("src/gui/InputSuggester/inputSuggester", () => ({
default: class {},
default: class {
static Suggest = inputSuggestMock;
},
}));

vi.mock("obsidian-dataview", () => ({
Expand Down Expand Up @@ -136,6 +160,15 @@ const createExecutor = (): IChoiceExecutor => ({
variables: new Map<string, unknown>(),
});

const createTFile = (path: string): TFile => {
const file = new TFile();
file.path = path;
file.name = path.split("/").pop() ?? path;
file.extension = file.name.split(".").pop() ?? "";
file.basename = file.name.replace(/\.[^.]+$/, "");
return file;
};

describe("CaptureChoiceEngine selection-as-value resolution", () => {
beforeEach(() => {
setUseSelectionAsCaptureValueMock.mockClear();
Expand Down Expand Up @@ -225,6 +258,9 @@ describe("CaptureChoiceEngine capture target resolution", () => {
beforeEach(() => {
vi.mocked(isFolder).mockReset();
vi.mocked(insertFileLinkToActiveView).mockReset();
vi.mocked(getMarkdownFilesInFolder).mockReset();
vi.mocked(getMarkdownFilesInFolder).mockReturnValue([]);
inputSuggestMock.mockReset();
setTitleMock.mockClear();
});

Expand Down Expand Up @@ -338,6 +374,99 @@ describe("CaptureChoiceEngine capture target resolution", () => {
expect(result).toBe("Boards/Map.CANVAS");
});

it.each([
["leading slash", "/Escape"],
["backslash", "Sub\\Escape"],
["absolute-looking drive path", "C:/Escape"],
["duplicate separator", "Sub//Escape"],
["current-directory segment", "./Escape"],
["parent-directory segment", "../Escape"],
])(
"rejects folder-scoped raw custom capture paths with %s before formatter or side effects",
async (_caseName, unsafeInput) => {
noticeClass.instances.length = 0;
formatFileNameMock.mockClear();
vi.mocked(insertFileLinkToActiveView).mockClear();
vi.mocked(openFile).mockClear();
vi.mocked(overwriteTemplaterOnce).mockClear();
vi.mocked(templaterParseTemplate).mockClear();

const existingFile = createTFile("Folder/Existing.md");
const app = createApp() as any;
app.vault.create = vi.fn(async () => createTFile("Folder/Created.md"));
app.vault.modify = vi.fn(async () => {});
app.vault.read = vi.fn(async () => "");
app.fileManager.processFrontMatter = vi.fn();
vi.mocked(getMarkdownFilesInFolder).mockReturnValue([existingFile]);
inputSuggestMock.mockResolvedValue(unsafeInput);

const engine = new CaptureChoiceEngine(
app,
{
settings: {
useSelectionAsCaptureValue: false,
showCaptureNotification: true,
},
} as any,
createChoice({
captureTo: "Folder/",
appendLink: true,
openFile: true,
createFileIfItDoesntExist: {
enabled: true,
createWithTemplate: false,
template: "",
},
}),
createExecutor(),
);
const fileExistsMock = vi.fn(async () => false);
(engine as any).vaultFileService.fileExists = fileExistsMock;

await engine.run();

expect(formatFileNameMock.mock.calls).toEqual([
["Folder/", "Capture Choice"],
]);
expect(formatFileNameMock).not.toHaveBeenCalledWith(
unsafeInput,
"Capture Choice",
);
expect(fileExistsMock).not.toHaveBeenCalled();
expect(app.vault.create).not.toHaveBeenCalled();
expect(app.vault.modify).not.toHaveBeenCalled();
expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled();
expect(templaterParseTemplate).not.toHaveBeenCalled();
expect(overwriteTemplaterOnce).not.toHaveBeenCalled();
expect(insertFileLinkToActiveView).not.toHaveBeenCalled();
expect(openFile).not.toHaveBeenCalled();
expect(
noticeClass.instances.some(({ message }) =>
message.startsWith("Created and captured to") ||
message.startsWith("Captured to"),
),
).toBe(false);
},
);

it("normalizes valid folder-scoped custom capture paths under the selected folder", async () => {
const existingFile = createTFile("Folder/Existing.md");
const app = createApp();
vi.mocked(getMarkdownFilesInFolder).mockReturnValue([existingFile]);
inputSuggestMock.mockResolvedValue("Sub/New Note");

const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ captureTo: "Folder/" }),
createExecutor(),
);

const result = await (engine as any).getFormattedPathToCaptureTo(false);

expect(result).toBe("Folder/Sub/New Note.md");
});

it("uses extensionless title for created .canvas capture files", async () => {
const app = createApp() as any;
app.vault.read = vi.fn(async () => "");
Expand Down Expand Up @@ -646,4 +775,77 @@ describe("CaptureChoiceEngine capture target resolution", () => {
expect(onFileExistsMock).not.toHaveBeenCalled();
expect(app.vault.modify).not.toHaveBeenCalled();
});

it("writes joined three-way merge results when an existing file changes without conflicts", async () => {
const file = createTFile("Inbox.md");
const app = createApp() as any;
app.vault.read = vi
.fn()
.mockResolvedValueOnce("original")
.mockResolvedValueOnce("concurrent");
app.vault.modify = vi.fn(async () => {});
app.vault.getAbstractFileByPath = vi.fn(() => file);
vi.mocked(merge).mockReturnValue({
isSuccess: vi.fn(() => true),
joinedResults: vi.fn(() => "merged"),
} as any);

const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice(),
createExecutor(),
);
(engine as any).vaultFileService.fileExists = vi.fn(async () => true);

await engine.run();

expect(merge).toHaveBeenCalledWith("concurrent", "original", "");
expect(app.vault.modify).toHaveBeenCalledWith(file, "merged");
});

it("aborts conflicting existing-file merges before write and follow-up side effects", async () => {
noticeClass.instances.length = 0;
vi.mocked(insertFileLinkToActiveView).mockClear();
vi.mocked(openFile).mockClear();

const file = createTFile("Inbox.md");
const app = createApp() as any;
app.vault.read = vi
.fn()
.mockResolvedValueOnce("original")
.mockResolvedValueOnce("concurrent");
app.vault.modify = vi.fn(async () => {});
app.vault.getAbstractFileByPath = vi.fn(() => file);
const processFrontMatter = vi.fn();
app.fileManager.processFrontMatter = processFrontMatter;
vi.mocked(merge).mockReturnValue({
isSuccess: vi.fn(() => false),
joinedResults: vi.fn(() => {
throw new Error("joinedResults should not be read on conflict");
}),
} as any);

const engine = new CaptureChoiceEngine(
app,
{
settings: {
useSelectionAsCaptureValue: false,
showCaptureNotification: true,
},
} as any,
createChoice({ appendLink: true, openFile: true }),
createExecutor(),
);
(engine as any).vaultFileService.fileExists = vi.fn(async () => true);

await engine.run();

expect(merge).toHaveBeenCalledWith("concurrent", "original", "");
expect(app.vault.modify).not.toHaveBeenCalled();
expect(processFrontMatter).not.toHaveBeenCalled();
expect(insertFileLinkToActiveView).not.toHaveBeenCalled();
expect(openFile).not.toHaveBeenCalled();
expect(noticeClass.instances).toEqual([]);
});
});
Loading
Loading