test: migrate component tests to new template architecture and restore 100% coverage

The "new template" refactor moved the backlink logic out of `Plugin` into
`BacklinkFullPathComponent`, but the tests still targeted the old `Plugin` API,
leaving 46 failing tests and broken coverage. Realign the suite with the
component architecture and the canonical template test layout.

- rewrite plugin.test.ts to verify onloadImpl wiring (mocked children)
- add backlink-full-path-component.test.ts covering the moved backlink logic
- add plugin-settings-component.test.ts
- rewrite plugin-settings-tab.test.ts using the bind-spy pattern

Coverage: 100% statements/branches/functions/lines.
This commit is contained in:
Michael Naumov 2026-06-18 21:07:14 -06:00
parent 6f0f2b8d4e
commit fcf4e4e242
4 changed files with 720 additions and 791 deletions

View file

@ -0,0 +1,565 @@
import type {
BacklinkView,
ResultDomItem,
ResultDomResult
} from '@obsidian-typings/obsidian-public-latest';
import type {
App,
TFile,
WorkspaceLeaf
} from 'obsidian';
import { ViewType } from '@obsidian-typings/obsidian-public-latest/implementations';
import { MarkdownView } from 'obsidian';
import { castTo } from 'obsidian-dev-utils/object-utils';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { PluginSettingsComponent } from './plugin-settings-component.ts';
import { BacklinkFullPathComponent } from './backlink-full-path-component.ts';
import { PluginSettings } from './plugin-settings.ts';
interface BacklinkDom {
addResult(file: TFile, result: ResultDomResult, content: string, shouldShowTitle: boolean): ResultDomItem;
}
interface ComponentInternals {
generateBacklinkTitle(file: TFile): HTMLDivElement;
getBacklinkView(): Promise<BacklinkView | null>;
onBacklinksCorePluginEnable(): void;
onLayoutReady(): Promise<void>;
patchBacklinksPane(): Promise<void>;
refreshBacklinkPanels(): Promise<void>;
reloadBacklinksView(): Promise<void>;
}
interface CorePlugin {
enabled: boolean;
instance: object;
}
interface PatchableTarget {
addResult?(...args: never[]): unknown;
onUserEnable?(...args: never[]): unknown;
}
interface PatchHandlerParams {
fallback(): unknown;
readonly originalArgs: readonly unknown[];
}
interface RegisteredCallbacksHolder {
registeredCallbacks: (() => void)[];
}
interface RegisterMethodPatchParams {
readonly methodName: 'addResult' | 'onUserEnable';
readonly obj: PatchableTarget;
patchHandler(params: PatchHandlerParams): unknown;
}
interface TestContext {
app: App;
component: BacklinkFullPathComponent;
getPluginById: ReturnType<typeof vi.fn>;
markdownLeaves: WorkspaceLeaf[];
on: ReturnType<typeof vi.fn>;
registeredCallbacks: (() => void)[];
settings: PluginSettings;
}
vi.mock('obsidian-dev-utils/async', () => ({
invokeAsyncSafely: vi.fn((fn: () => Promise<void>) => fn())
}));
vi.mock('obsidian-dev-utils/obsidian/components/layout-ready-component', () => ({
LayoutReadyComponent: class MockLayoutReadyComponent {
public app: App;
public readonly registeredCallbacks: (() => void)[] = [];
public constructor(app: App) {
this.app = app;
}
public addChild(child: unknown): unknown {
return child;
}
public register(callback: () => void): void {
this.registeredCallbacks.push(callback);
}
}
}));
vi.mock('obsidian-dev-utils/obsidian/components/monkey-around-component', () => ({
MonkeyAroundComponent: class MockMonkeyAroundComponent {
public registerMethodPatch(params: RegisterMethodPatchParams): void {
const {
methodName,
obj,
patchHandler
} = params;
const originalMethod = obj[methodName];
obj[methodName] = function patched(this: unknown, ...originalArgs: never[]): unknown {
return patchHandler({
fallback: () => originalMethod?.apply(this, originalArgs),
originalArgs
});
};
}
}
}));
function asInternals(component: BacklinkFullPathComponent): ComponentInternals {
return castTo<ComponentInternals>(component);
}
function createCorePlugin(enabled: boolean, instanceProto: object = {}): CorePlugin {
return {
enabled,
instance: Object.create(instanceProto)
};
}
function createMarkdownLeaf(backlinks: unknown): WorkspaceLeaf {
return castTo<WorkspaceLeaf>({
view: Object.assign(Object.create(MarkdownView.prototype), { backlinks })
});
}
function createMockFile(path: string): TFile {
const parts = path.split('/');
const name = parts.at(-1) ?? '';
const dotIndex = name.lastIndexOf('.');
const basename = dotIndex >= 0 ? name.slice(0, dotIndex) : name;
const extension = dotIndex >= 0 ? name.slice(dotIndex + 1) : '';
return strictProxy<TFile>({
basename,
extension,
name,
path
});
}
function createMockResultDomItem(hasTreeItemInner: boolean): ResultDomItem {
const el = activeDocument.createElement('div');
if (hasTreeItemInner) {
const inner = activeDocument.createElement('div');
inner.classList.add('tree-item-inner');
inner.textContent = 'Original';
el.appendChild(inner);
}
return strictProxy<ResultDomItem>({ el });
}
function createTestContext(): TestContext {
const settings = new PluginSettings();
const markdownLeaves: WorkspaceLeaf[] = [];
const getPluginById = vi.fn();
const on = vi.fn();
const app = strictProxy<App>({
internalPlugins: {
getPluginById
},
workspace: {
getLeavesOfType: vi.fn().mockImplementation((type: string) => {
if (type === ViewType.Markdown) {
return markdownLeaves;
}
return [];
})
}
});
const pluginSettingsComponent = strictProxy<PluginSettingsComponent>({
on,
settings
});
const component = new BacklinkFullPathComponent({
app,
pluginSettingsComponent
});
const registeredCallbacks = castTo<RegisteredCallbacksHolder>(component).registeredCallbacks;
return {
app,
component,
getPluginById,
markdownLeaves,
on,
registeredCallbacks,
settings
};
}
describe('BacklinkFullPathComponent', () => {
let context: TestContext;
beforeEach(() => {
context = createTestContext();
});
describe('onLayoutReady', () => {
it('should register a saveSettings handler that refreshes panels', async () => {
context.getPluginById.mockReturnValue(undefined);
const refreshSpy = vi.spyOn(asInternals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined);
await asInternals(context.component).onLayoutReady();
expect(context.on).toHaveBeenCalledWith('saveSettings', expect.any(Function));
const handler = context.on.mock.calls[0]?.[1] as () => Promise<void>;
await handler();
expect(refreshSpy).toHaveBeenCalled();
});
it('should return early when backlinks core plugin is not found', async () => {
context.getPluginById.mockReturnValue(undefined);
const patchSpy = vi.spyOn(asInternals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
await asInternals(context.component).onLayoutReady();
expect(patchSpy).not.toHaveBeenCalled();
});
it('should patch onUserEnable to call onBacklinksCorePluginEnable', async () => {
const originalOnUserEnable = vi.fn();
const instanceProto = { onUserEnable: originalOnUserEnable };
const corePlugin = createCorePlugin(false, instanceProto);
context.getPluginById.mockReturnValue(corePlugin);
const enableSpy = vi.spyOn(asInternals(context.component), 'onBacklinksCorePluginEnable').mockReturnValue(undefined);
await asInternals(context.component).onLayoutReady();
instanceProto.onUserEnable.call(corePlugin.instance);
expect(originalOnUserEnable).toHaveBeenCalled();
expect(enableSpy).toHaveBeenCalled();
});
it('should patch pane and refresh panels when plugin is enabled', async () => {
context.getPluginById.mockReturnValue(createCorePlugin(true));
const patchSpy = vi.spyOn(asInternals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
const refreshSpy = vi.spyOn(asInternals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined);
await asInternals(context.component).onLayoutReady();
expect(patchSpy).toHaveBeenCalled();
expect(refreshSpy).toHaveBeenCalled();
});
it('should not patch pane when plugin is disabled', async () => {
context.getPluginById.mockReturnValue(createCorePlugin(false));
const patchSpy = vi.spyOn(asInternals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
await asInternals(context.component).onLayoutReady();
expect(patchSpy).not.toHaveBeenCalled();
});
it('should register an unload callback that refreshes panels', async () => {
context.getPluginById.mockReturnValue(createCorePlugin(false));
const refreshSpy = vi.spyOn(asInternals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined);
await asInternals(context.component).onLayoutReady();
expect(context.registeredCallbacks.length).toBeGreaterThan(0);
const callback = context.registeredCallbacks[0];
callback?.();
expect(refreshSpy).toHaveBeenCalled();
});
});
describe('generateBacklinkTitle', () => {
function callGenerateBacklinkTitle(file: TFile): HTMLDivElement {
return asInternals(context.component).generateBacklinkTitle.call(context.component, file);
}
it('should include extension when shouldIncludeExtension is true', () => {
context.settings.shouldIncludeExtension = true;
const container = callGenerateBacklinkTitle(createMockFile('folder/note.md'));
const fileNameSpan = container.shadowRoot?.querySelector('[part="file-name"]');
expect(fileNameSpan?.textContent).toBe('note.md');
});
it('should exclude extension when shouldIncludeExtension is false', () => {
context.settings.shouldIncludeExtension = false;
const container = callGenerateBacklinkTitle(createMockFile('folder/note.md'));
const fileNameSpan = container.shadowRoot?.querySelector('[part="file-name"]');
expect(fileNameSpan?.textContent).toBe('note');
});
it('should show file at root with no parent path', () => {
const container = callGenerateBacklinkTitle(createMockFile('note.md'));
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan).toBeNull();
});
it('should show parent path with trailing separator', () => {
context.settings.shouldReversePathParts = false;
context.settings.shouldDisplayParentPathOnSeparateLine = false;
const container = callGenerateBacklinkTitle(createMockFile('folder/note.md'));
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('folder/');
});
it('should strip root path prefix', () => {
context.settings.rootPaths = ['folder'];
const container = callGenerateBacklinkTitle(createMockFile('folder/subfolder/note.md'));
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('subfolder/');
});
it('should strip nested root path prefix', () => {
context.settings.rootPaths = ['folder/subfolder'];
const container = callGenerateBacklinkTitle(createMockFile('folder/subfolder/deep/note.md'));
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('deep/');
});
it('should limit depth with pathDepth setting', () => {
context.settings.pathDepth = 2;
context.settings.shouldShowEllipsisForSkippedPathParts = false;
const container = callGenerateBacklinkTitle(createMockFile('a/b/c/note.md'));
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('c/');
});
it('should add ellipsis for skipped parts when enabled', () => {
context.settings.pathDepth = 2;
context.settings.shouldShowEllipsisForSkippedPathParts = true;
const container = callGenerateBacklinkTitle(createMockFile('a/b/c/note.md'));
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('.../c/');
});
it('should not add ellipsis when pathDepth does not truncate', () => {
context.settings.pathDepth = 5;
context.settings.shouldShowEllipsisForSkippedPathParts = true;
const container = callGenerateBacklinkTitle(createMockFile('a/b/note.md'));
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('a/b/');
});
it('should reverse path parts when shouldReversePathParts is true', () => {
context.settings.shouldReversePathParts = true;
context.settings.shouldDisplayParentPathOnSeparateLine = false;
const container = callGenerateBacklinkTitle(createMockFile('a/b/note.md'));
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe(' ← b ← a');
});
it('should display parent path on separate line without separator', () => {
context.settings.shouldDisplayParentPathOnSeparateLine = true;
context.settings.shouldReversePathParts = false;
const container = callGenerateBacklinkTitle(createMockFile('folder/note.md'));
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('folder');
});
it('should set dataset attributes for highlighting', () => {
context.settings.shouldHighlightFileName = true;
context.settings.shouldDisplayParentPathOnSeparateLine = false;
const container = callGenerateBacklinkTitle(createMockFile('note.md'));
expect(container.dataset['shouldHighlightFileName']).toBe('true');
expect(container.dataset['shouldDisplayParentPathOnSeparateLine']).toBe('false');
});
it('should include full-path span with file path', () => {
const container = callGenerateBacklinkTitle(createMockFile('folder/note.md'));
const fullPathSpan = container.querySelector('.full-path');
expect(fullPathSpan?.textContent).toBe('folder/note.md');
});
it('should prepend parent path when not reversed and not on separate line', () => {
context.settings.shouldReversePathParts = false;
context.settings.shouldDisplayParentPathOnSeparateLine = false;
const container = callGenerateBacklinkTitle(createMockFile('folder/note.md'));
const firstChild = container.shadowRoot?.firstElementChild;
expect(firstChild?.getAttribute('part')).toBe('parent-path');
});
it('should append parent path when reversed and not on separate line', () => {
context.settings.shouldReversePathParts = true;
context.settings.shouldDisplayParentPathOnSeparateLine = false;
const container = callGenerateBacklinkTitle(createMockFile('folder/note.md'));
const lastChild = container.shadowRoot?.lastElementChild;
expect(lastChild?.getAttribute('part')).toBe('parent-path');
});
it('should not prepend when displaying on separate line', () => {
context.settings.shouldReversePathParts = false;
context.settings.shouldDisplayParentPathOnSeparateLine = true;
const container = callGenerateBacklinkTitle(createMockFile('folder/note.md'));
const lastChild = container.shadowRoot?.lastElementChild;
expect(lastChild?.getAttribute('part')).toBe('parent-path');
});
});
describe('getBacklinkView', () => {
it('should return null when no backlink leaf exists', async () => {
const view = await asInternals(context.component).getBacklinkView();
expect(view).toBeNull();
});
it('should return the view when a backlink leaf exists', async () => {
const mockView = strictProxy<BacklinkView>({});
const loadIfDeferred = vi.fn().mockResolvedValue(undefined);
const mockLeaf = castTo<WorkspaceLeaf>({
loadIfDeferred,
view: mockView
});
vi.mocked(context.app.workspace.getLeavesOfType).mockImplementation((type: string) => {
if (type === ViewType.Backlink) {
return [mockLeaf];
}
return [];
});
const view = await asInternals(context.component).getBacklinkView();
expect(loadIfDeferred).toHaveBeenCalled();
expect(view).toBe(mockView);
});
});
describe('onBacklinksCorePluginEnable', () => {
it('should invoke patchBacklinksPane', () => {
const patchSpy = vi.spyOn(asInternals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
asInternals(context.component).onBacklinksCorePluginEnable();
expect(patchSpy).toHaveBeenCalled();
});
});
describe('patchBacklinksPane', () => {
it('should return early when no backlink view exists', async () => {
const getViewSpy = vi.spyOn(asInternals(context.component), 'getBacklinkView').mockResolvedValue(null);
await asInternals(context.component).patchBacklinksPane();
expect(getViewSpy).toHaveBeenCalled();
});
it('should patch addResult to replace tree-item-inner with generated title', async () => {
const originalAddResult = vi.fn().mockImplementation(() => createMockResultDomItem(true));
const backlinkDomProto: BacklinkDom = { addResult: originalAddResult };
const backlinkView = strictProxy<BacklinkView>({
backlink: strictProxy({
backlinkDom: Object.create(backlinkDomProto)
})
});
vi.spyOn(asInternals(context.component), 'getBacklinkView').mockResolvedValue(backlinkView);
await asInternals(context.component).patchBacklinksPane();
const file = createMockFile('folder/note.md');
const result = strictProxy<ResultDomResult>({});
const item = backlinkDomProto.addResult(file, result, 'content', true);
const inner = item.el.querySelector('.tree-item-inner');
expect(inner?.textContent).not.toBe('Original');
});
it('should leave result unchanged when tree-item-inner is absent', async () => {
const originalAddResult = vi.fn().mockImplementation(() => createMockResultDomItem(false));
const backlinkDomProto: BacklinkDom = { addResult: originalAddResult };
const backlinkView = strictProxy<BacklinkView>({
backlink: strictProxy({
backlinkDom: Object.create(backlinkDomProto)
})
});
vi.spyOn(asInternals(context.component), 'getBacklinkView').mockResolvedValue(backlinkView);
await asInternals(context.component).patchBacklinksPane();
const file = createMockFile('note.md');
const result = strictProxy<ResultDomResult>({});
const item = backlinkDomProto.addResult(file, result, 'content', true);
expect(item.el.querySelector('.tree-item-inner')).toBeNull();
});
});
describe('reloadBacklinksView', () => {
it('should do nothing when no backlink view exists', async () => {
const getViewSpy = vi.spyOn(asInternals(context.component), 'getBacklinkView').mockResolvedValue(null);
await asInternals(context.component).reloadBacklinksView();
expect(getViewSpy).toHaveBeenCalled();
});
it('should not recompute when backlink view has no file', async () => {
const recomputeBacklink = vi.fn();
const backlinkView = strictProxy<BacklinkView>({
backlink: strictProxy({ recomputeBacklink }),
file: null
});
vi.spyOn(asInternals(context.component), 'getBacklinkView').mockResolvedValue(backlinkView);
await asInternals(context.component).reloadBacklinksView();
expect(recomputeBacklink).not.toHaveBeenCalled();
});
it('should recompute when backlink view has a file', async () => {
const mockFile = createMockFile('note.md');
const recomputeBacklink = vi.fn();
const backlinkView = strictProxy<BacklinkView>({
backlink: strictProxy({ recomputeBacklink }),
file: mockFile
});
vi.spyOn(asInternals(context.component), 'getBacklinkView').mockResolvedValue(backlinkView);
await asInternals(context.component).reloadBacklinksView();
expect(recomputeBacklink).toHaveBeenCalledWith(mockFile);
});
});
describe('refreshBacklinkPanels', () => {
beforeEach(() => {
vi.spyOn(asInternals(context.component), 'reloadBacklinksView').mockResolvedValue(undefined);
});
it('should skip leaves that are not MarkdownView instances', async () => {
context.markdownLeaves.push(castTo<WorkspaceLeaf>({ view: {} }));
await asInternals(context.component).refreshBacklinkPanels();
expect(asInternals(context.component).reloadBacklinksView).toHaveBeenCalled();
});
it('should skip MarkdownView leaves without backlinks', async () => {
context.markdownLeaves.push(createMarkdownLeaf(undefined));
await asInternals(context.component).refreshBacklinkPanels();
expect(asInternals(context.component).reloadBacklinksView).toHaveBeenCalled();
});
it('should recompute backlinks for MarkdownView leaves with backlinks', async () => {
const mockFile = createMockFile('test2.md');
const recomputeBacklink = vi.fn();
context.markdownLeaves.push(createMarkdownLeaf({ file: mockFile, recomputeBacklink }));
await asInternals(context.component).refreshBacklinkPanels();
expect(recomputeBacklink).toHaveBeenCalledWith(mockFile);
});
});
});

View file

@ -0,0 +1,26 @@
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
describe,
expect,
it
} from 'vitest';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettings } from './plugin-settings.ts';
describe('PluginSettingsComponent', () => {
it('should create with PluginSettings class', () => {
const dataHandler = strictProxy<DataHandler>({});
const pluginEventSource = strictProxy<PluginEventSource>({});
const component = new PluginSettingsComponent({
dataHandler,
pluginEventSource
});
expect(component.settings).toBeInstanceOf(PluginSettings);
});
});

View file

@ -1,133 +1,91 @@
import type { PluginManifest } from 'obsidian';
import type { Plugin } from 'obsidian';
import type { PluginSettingsComponentBase } from 'obsidian-dev-utils/obsidian/components/plugin-settings-component';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
App,
ToggleComponent
} from 'obsidian-test-mocks/obsidian';
import {
beforeAll,
describe,
expect,
it
it,
vi
} from 'vitest';
import type { PluginSettings } from './plugin-settings.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
import { Plugin } from './plugin.ts';
interface DisplayedTabResult {
readonly names: string[];
readonly tab: PluginSettingsTab;
}
vi.mock('obsidian-dev-utils/obsidian/setting-ex', () => {
class MockSettingEx {
public constructor(public readonly containerEl: HTMLElement) {
}
function createDisplayedTab(): DisplayedTabResult {
const plugin = createPlugin();
const pluginSettingsComponent = Reflect.get(plugin, 'pluginSettingsComponent') as InstanceType<
typeof import('./plugin-settings-component.ts').PluginSettingsComponent
>;
public addMultipleText(cb: (component: unknown) => void): this {
cb({ setMin: vi.fn() });
return this;
}
const tab = new PluginSettingsTab({
plugin,
pluginSettingsComponent
});
public addNumber(cb: (component: unknown) => void): this {
cb({ setMin: vi.fn() });
return this;
}
tab.displayLegacy();
const names = getSettingNames(tab.containerEl);
return { names, tab };
}
public setDesc(): this {
return this;
}
function createPlugin(): Plugin {
const app = App.createConfigured__();
const manifest: PluginManifest = {
author: 'test',
description: 'test',
id: 'backlink-full-path',
minAppVersion: '0.0.0',
name: 'Backlink Full Path',
version: '1.0.0'
};
return new Plugin(app.asOriginalType__(), manifest);
}
function getSettingNames(containerEl: HTMLElement): string[] {
const names: string[] = [];
for (const settingEl of containerEl.children) {
// SettingEl contains [controlEl, infoEl].
// InfoEl contains [nameEl, descEl].
const infoEl = settingEl.children[1];
if (infoEl) {
const nameEl = infoEl.children[0];
if (nameEl?.textContent) {
names.push(nameEl.textContent);
}
public setName(): this {
return this;
}
}
return names;
}
function patchMissingMockProperties(): void {
// Obsidian-dev-utils checks for setPlaceholderValue to detect text-based components.
if (!('setPlaceholderValue' in ToggleComponent.prototype)) {
Object.defineProperty(ToggleComponent.prototype, 'setPlaceholderValue', { value: undefined });
}
}
beforeAll(() => {
patchMissingMockProperties();
return { SettingEx: MockSettingEx };
});
describe('PluginSettingsTab', () => {
it('should be constructable', () => {
const plugin = createPlugin();
const pluginSettingsComponent = Reflect.get(plugin, 'pluginSettingsComponent') as InstanceType<
typeof import('./plugin-settings-component.ts').PluginSettingsComponent
>;
it('should display all settings bound to the correct properties', () => {
const settings = strictProxy<PluginSettings>({
pathDepth: 0,
rootPaths: [],
shouldDisplayParentPathOnSeparateLine: false,
shouldHighlightFileName: true,
shouldIncludeExtension: true,
shouldReversePathParts: false,
shouldShowEllipsisForSkippedPathParts: true
});
const pluginSettingsComponent = strictProxy<PluginSettingsComponentBase<PluginSettings>>({
on: vi.fn().mockReturnValue({ id: 'ref' }),
settings
});
const plugin = strictProxy<Plugin>({
app: {
workspace: {
on: vi.fn().mockReturnValue({ id: 'test' })
}
}
});
const tab = new PluginSettingsTab({
plugin,
pluginSettingsComponent
});
expect(tab).toBeInstanceOf(PluginSettingsTab);
});
describe('display', () => {
it('should create all 7 settings', () => {
const { names } = createDisplayedTab();
const EXPECTED_SETTING_COUNT = 7;
expect(names.length).toBe(EXPECTED_SETTING_COUNT);
});
tab.containerEl = activeDocument.createElement('div');
it('should create Include extension setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Include extension');
});
const bindSpy = vi.spyOn(tab, 'bind').mockReturnValue({ setMin: vi.fn() });
it('should create Path depth setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Path depth');
});
tab.displayLegacy();
it('should create Show ellipsis for skipped path parts setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Show ellipsis for skipped path parts');
});
it('should create Highlight file name setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Highlight file name');
});
it('should create Reverse path parts setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Reverse path parts');
});
it('should create Display parent path on separate line setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Display parent path on separate line');
});
it('should create Root paths setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Root paths');
});
const EXPECTED_BIND_COUNT = 7;
expect(bindSpy).toHaveBeenCalledTimes(EXPECTED_BIND_COUNT);
expect(bindSpy.mock.calls.map((call) => call[1])).toEqual([
'shouldIncludeExtension',
'pathDepth',
'shouldShowEllipsisForSkippedPathParts',
'shouldHighlightFileName',
'shouldReversePathParts',
'shouldDisplayParentPathOnSeparateLine',
'rootPaths'
]);
});
});

View file

@ -1,710 +1,90 @@
import type {
BacklinkView,
ResultDom,
ResultDomItem,
ResultDomResult
} from '@obsidian-typings/obsidian-public-latest';
import type {
PluginManifest,
TFile
} from 'obsidian';
import type { StrictProxyPartial } from 'obsidian-dev-utils/strict-proxy';
import {
bypassStrictProxy,
strictProxy
} from 'obsidian-dev-utils/strict-proxy';
import {
App,
MarkdownView,
WorkspaceLeaf
} from 'obsidian-test-mocks/obsidian';
PluginManifest
} from 'obsidian';
import { castTo } from 'obsidian-dev-utils/object-utils';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { PluginSettings } from './plugin-settings.ts';
import { BacklinkFullPathComponent } from './backlink-full-path-component.ts';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
import { Plugin } from './plugin.ts';
interface MockCorePlugin {
enabled: boolean;
instance: object;
}
vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-tab-component', () => ({
PluginSettingsTabComponent: vi.fn()
}));
interface SettingsComponentProxy {
on(...args: never[]): void;
settings: PluginSettings;
}
vi.mock('obsidian-dev-utils/obsidian/data-handler', () => ({
PluginDataHandler: vi.fn()
}));
interface TestPluginResult {
readonly app: ReturnType<typeof App.createConfigured__>;
readonly plugin: Plugin;
}
vi.mock('obsidian-dev-utils/obsidian/plugin/plugin', () => {
class MockPluginBase {
public app: App;
public manifest: PluginManifest;
function createMockFile(path: string): TFile {
const parts = path.split('/');
const name = parts.at(-1) ?? '';
const dotIndex = name.lastIndexOf('.');
const basename = dotIndex >= 0 ? name.slice(0, dotIndex) : name;
const extension = dotIndex >= 0 ? name.slice(dotIndex + 1) : '';
return mockProxy<TFile>({
basename,
extension,
name,
path
});
}
public constructor(app: App, manifest: PluginManifest) {
this.app = app;
this.manifest = manifest;
}
function createMockResultDomItem(hasTreeItemInner: boolean): ResultDomItem {
const el = activeDocument.createElement('div');
if (hasTreeItemInner) {
const inner = activeDocument.createElement('div');
inner.classList.add('tree-item-inner');
inner.textContent = 'Original';
el.appendChild(inner);
public addChild(child: unknown): unknown {
return child;
}
}
return mockProxy<ResultDomItem>({ el });
return { PluginBase: MockPluginBase };
});
vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-event-source', () => ({
PluginEventSourceImpl: vi.fn()
}));
vi.mock('./backlink-full-path-component.ts', () => ({
BacklinkFullPathComponent: vi.fn()
}));
vi.mock('./plugin-settings-component.ts', () => ({
PluginSettingsComponent: vi.fn()
}));
vi.mock('./plugin-settings-tab.ts', () => ({
PluginSettingsTab: vi.fn()
}));
interface PluginInternals {
onloadImpl(): void;
}
function createTestManifest(): PluginManifest {
return {
author: 'test',
description: 'test',
function createMockApp(): App {
return strictProxy<App>({});
}
function createMockManifest(): PluginManifest {
return strictProxy<PluginManifest>({
id: 'backlink-full-path',
minAppVersion: '0.0.0',
name: 'Backlink Full Path',
version: '1.0.0'
};
}
function createTestPlugin(): TestPluginResult {
const app = App.createConfigured__();
const manifest = createTestManifest();
const plugin = new Plugin(app.asOriginalType__(), manifest);
return { app, plugin };
}
function getSettingsComponent(plugin: Plugin): SettingsComponentProxy {
return Reflect.get(plugin, 'pluginSettingsComponent') as SettingsComponentProxy;
}
function markPluginLoaded(plugin: Plugin): void {
Reflect.set(bypassStrictProxy(plugin), '_loaded', true);
const wrapperComponent = Reflect.get(bypassStrictProxy(plugin), 'wrapperComponent') as object;
Reflect.set(bypassStrictProxy(wrapperComponent), '_loaded', true);
}
function mockInternalPlugins(plugin: Plugin, returnValue: unknown): ReturnType<typeof vi.fn> {
const getPluginById = vi.fn().mockReturnValue(returnValue);
Reflect.set(bypassStrictProxy(plugin.app), 'internalPlugins', strictProxy({ getPluginById }));
return getPluginById;
}
function mockObsidianDevUtilsState(plugin: Plugin): void {
Reflect.set(bypassStrictProxy(plugin.app), 'obsidianDevUtilsState', {});
const globalApp = Reflect.get(window, 'app') as unknown;
if (globalApp) {
Reflect.set(bypassStrictProxy(globalApp), 'obsidianDevUtilsState', {});
}
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T is used in the body for strictProxy<T>.
function mockProxy<T>(partial: object): T {
return strictProxy<T>(partial as StrictProxyPartial<T>);
}
function privateMethod(obj: object, name: string): (...args: never[]) => unknown {
const fn = Reflect.get(obj, name) as (...args: never[]) => unknown;
return fn.bind(obj);
name: 'Backlink Full Path'
});
}
describe('Plugin', () => {
it('should be constructable', () => {
const { plugin } = createTestPlugin();
expect(plugin).toBeInstanceOf(Plugin);
});
it('should have the correct manifest id', () => {
const { plugin } = createTestPlugin();
expect(plugin.manifest.id).toBe('backlink-full-path');
});
describe('onload', () => {
it('should register saveSettings event listener', async () => {
const { plugin } = createTestPlugin();
mockObsidianDevUtilsState(plugin);
const settingsComponent = getSettingsComponent(plugin);
const onSpy = vi.spyOn(settingsComponent, 'on');
await plugin.onload();
expect(onSpy).toHaveBeenCalledWith('saveSettings', expect.any(Function));
});
it('should call refreshBacklinkPanels when saveSettings is triggered', async () => {
const { plugin } = createTestPlugin();
mockObsidianDevUtilsState(plugin);
const refreshBacklinkPanels = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'refreshBacklinkPanels', refreshBacklinkPanels);
const settingsComponent = getSettingsComponent(plugin);
let savedCallback: (() => Promise<void>) | undefined;
vi.spyOn(settingsComponent, 'on').mockImplementation(
((_event: never, callback: never) => {
savedCallback = callback;
}) as (...args: never[]) => void
);
await plugin.onload();
expect(savedCallback).toBeDefined();
if (savedCallback) {
await savedCallback();
}
expect(refreshBacklinkPanels).toHaveBeenCalled();
});
});
describe('onLayoutReady', () => {
it('should return early when backlinks core plugin is not found', async () => {
const { plugin } = createTestPlugin();
const getPluginById = mockInternalPlugins(plugin, undefined);
const onLayoutReady = privateMethod(plugin, 'onLayoutReady');
await onLayoutReady();
expect(getPluginById).toHaveBeenCalledWith('backlink');
});
it('should patch but not call patchBacklinksPane when backlinks plugin is disabled', async () => {
const { plugin } = createTestPlugin();
markPluginLoaded(plugin);
const instanceProto = {};
const backlinksCorePlugin = mockProxy<MockCorePlugin>({
enabled: false,
instance: Object.create(instanceProto)
});
mockInternalPlugins(plugin, backlinksCorePlugin);
const patchBacklinksPane = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'patchBacklinksPane', patchBacklinksPane);
const onLayoutReady = privateMethod(plugin, 'onLayoutReady');
await onLayoutReady();
expect(patchBacklinksPane).not.toHaveBeenCalled();
});
it('should call patchBacklinksPane and refreshBacklinkPanels when backlinks plugin is enabled', async () => {
const { plugin } = createTestPlugin();
markPluginLoaded(plugin);
const instanceProto = {};
const backlinksCorePlugin = mockProxy<MockCorePlugin>({
enabled: true,
instance: Object.create(instanceProto)
});
mockInternalPlugins(plugin, backlinksCorePlugin);
const patchBacklinksPane = vi.fn().mockResolvedValue(undefined);
const refreshBacklinkPanels = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'patchBacklinksPane', patchBacklinksPane);
Reflect.set(plugin, 'refreshBacklinkPanels', refreshBacklinkPanels);
const onLayoutReady = privateMethod(plugin, 'onLayoutReady');
await onLayoutReady();
expect(patchBacklinksPane).toHaveBeenCalled();
expect(refreshBacklinkPanels).toHaveBeenCalled();
});
it('should invoke onUserEnable patch that calls onBacklinksCorePluginEnable', async () => {
const { plugin } = createTestPlugin();
markPluginLoaded(plugin);
const instanceProto = { onUserEnable: vi.fn() };
const backlinksCorePlugin = mockProxy<MockCorePlugin>({
enabled: false,
instance: Object.create(instanceProto) as object
});
mockInternalPlugins(plugin, backlinksCorePlugin);
const onBacklinksCorePluginEnable = vi.fn();
Reflect.set(plugin, 'onBacklinksCorePluginEnable', onBacklinksCorePluginEnable);
const onLayoutReady = privateMethod(plugin, 'onLayoutReady');
await onLayoutReady();
const patchedOnUserEnable = instanceProto.onUserEnable as () => void;
patchedOnUserEnable.call(backlinksCorePlugin.instance);
expect(onBacklinksCorePluginEnable).toHaveBeenCalled();
});
it('should register unload callback that calls refreshBacklinkPanels', async () => {
const { plugin } = createTestPlugin();
markPluginLoaded(plugin);
const instanceProto = {};
const backlinksCorePlugin = mockProxy<MockCorePlugin>({
enabled: false,
instance: Object.create(instanceProto)
});
mockInternalPlugins(plugin, backlinksCorePlugin);
const refreshBacklinkPanels = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'refreshBacklinkPanels', refreshBacklinkPanels);
const registerCallbacks: (() => void)[] = [];
vi.spyOn(plugin, 'register').mockImplementation((cb: () => void) => {
registerCallbacks.push(cb);
});
const onLayoutReady = privateMethod(plugin, 'onLayoutReady');
await onLayoutReady();
expect(registerCallbacks.length).toBeGreaterThan(0);
const callback = registerCallbacks[0];
if (callback) {
callback();
}
expect(refreshBacklinkPanels).toHaveBeenCalled();
});
});
describe('addResult', () => {
let plugin: Plugin;
let addResult: (...args: never[]) => unknown;
beforeEach(() => {
const result = createTestPlugin();
plugin = result.plugin;
addResult = privateMethod(plugin, 'addResult');
});
it('should replace tree-item-inner content with generated backlink title', () => {
const file = createMockFile('folder/subfolder/note.md');
const resultDomItem = createMockResultDomItem(true);
const next = vi.fn().mockReturnValue(resultDomItem);
const resultDom = mockProxy<ResultDom>({});
const result = mockProxy<ResultDomResult>({});
const returnedItem = (addResult as (...args: unknown[]) => unknown)(next, resultDom, file, result, 'content', true) as ResultDomItem;
expect(next).toHaveBeenCalledWith(file, result, 'content', true);
expect(returnedItem).toBe(resultDomItem);
const inner = resultDomItem.el.querySelector('.tree-item-inner');
expect(inner?.textContent).not.toBe('Original');
});
it('should not modify result when tree-item-inner is absent', () => {
const file = createMockFile('note.md');
const resultDomItem = createMockResultDomItem(false);
const next = vi.fn().mockReturnValue(resultDomItem);
const resultDom = mockProxy<ResultDom>({});
const result = mockProxy<ResultDomResult>({});
const returnedItem = (addResult as (...args: unknown[]) => unknown)(next, resultDom, file, result, 'content') as ResultDomItem;
expect(returnedItem).toBe(resultDomItem);
});
});
describe('generateBacklinkTitle', () => {
let plugin: Plugin;
let settings: PluginSettings;
beforeEach(() => {
const result = createTestPlugin();
plugin = result.plugin;
settings = getSettingsComponent(plugin).settings;
});
function callGenerateBacklinkTitle(file: TFile): HTMLDivElement {
const fn = privateMethod(plugin, 'generateBacklinkTitle');
return (fn as (...args: unknown[]) => unknown)(file) as HTMLDivElement;
}
it('should include extension when shouldIncludeExtension is true', () => {
settings.shouldIncludeExtension = true;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const fileNameSpan = container.shadowRoot?.querySelector('[part="file-name"]');
expect(fileNameSpan?.textContent).toBe('note.md');
});
it('should exclude extension when shouldIncludeExtension is false', () => {
settings.shouldIncludeExtension = false;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const fileNameSpan = container.shadowRoot?.querySelector('[part="file-name"]');
expect(fileNameSpan?.textContent).toBe('note');
});
it('should show file at root with no parent path', () => {
const file = createMockFile('note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan).toBeNull();
});
it('should show parent path with trailing separator', () => {
settings.shouldReversePathParts = false;
settings.shouldDisplayParentPathOnSeparateLine = false;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('folder/');
});
it('should strip root path prefix', () => {
settings.rootPaths = ['folder'];
const file = createMockFile('folder/subfolder/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('subfolder/');
});
it('should strip nested root path prefix', () => {
settings.rootPaths = ['folder/subfolder'];
const file = createMockFile('folder/subfolder/deep/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('deep/');
});
it('should limit depth with pathDepth setting', () => {
settings.pathDepth = 2;
settings.shouldShowEllipsisForSkippedPathParts = false;
const file = createMockFile('a/b/c/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('c/');
});
it('should add ellipsis for skipped parts when enabled', () => {
settings.pathDepth = 2;
settings.shouldShowEllipsisForSkippedPathParts = true;
const file = createMockFile('a/b/c/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('.../c/');
});
it('should not add ellipsis when pathDepth does not truncate', () => {
settings.pathDepth = 5;
settings.shouldShowEllipsisForSkippedPathParts = true;
const file = createMockFile('a/b/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('a/b/');
});
it('should reverse path parts when shouldReversePathParts is true', () => {
settings.shouldReversePathParts = true;
settings.shouldDisplayParentPathOnSeparateLine = false;
const file = createMockFile('a/b/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe(' \u2190 b \u2190 a');
});
it('should display parent path on separate line without separator', () => {
settings.shouldDisplayParentPathOnSeparateLine = true;
settings.shouldReversePathParts = false;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('folder');
});
it('should set dataset attributes for highlighting', () => {
settings.shouldHighlightFileName = true;
settings.shouldDisplayParentPathOnSeparateLine = false;
const file = createMockFile('note.md');
const container = callGenerateBacklinkTitle(file);
expect(container.dataset['shouldHighlightFileName']).toBe('true');
expect(container.dataset['shouldDisplayParentPathOnSeparateLine']).toBe('false');
});
it('should include full-path span with file path', () => {
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const fullPathSpan = container.querySelector('.full-path');
expect(fullPathSpan?.textContent).toBe('folder/note.md');
});
it('should prepend parent path when not reversed and not on separate line', () => {
settings.shouldReversePathParts = false;
settings.shouldDisplayParentPathOnSeparateLine = false;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const firstChild = container.shadowRoot?.firstElementChild;
expect(firstChild?.getAttribute('part')).toBe('parent-path');
});
it('should append parent path when reversed and not on separate line', () => {
settings.shouldReversePathParts = true;
settings.shouldDisplayParentPathOnSeparateLine = false;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const lastChild = container.shadowRoot?.lastElementChild;
expect(lastChild?.getAttribute('part')).toBe('parent-path');
});
it('should not prepend when displaying on separate line', () => {
settings.shouldReversePathParts = false;
settings.shouldDisplayParentPathOnSeparateLine = true;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const lastChild = container.shadowRoot?.lastElementChild;
expect(lastChild?.getAttribute('part')).toBe('parent-path');
});
});
describe('getBacklinkView', () => {
it('should return null when no backlink leaf exists', async () => {
const { plugin } = createTestPlugin();
const getBacklinkView = privateMethod(plugin, 'getBacklinkView');
const view = await getBacklinkView();
expect(view).toBeNull();
});
it('should return the view when a backlink leaf exists', async () => {
const { plugin } = createTestPlugin();
const mockView = mockProxy<BacklinkView>({});
const mockLeaf = mockProxy<WorkspaceLeaf>({
loadIfDeferred: vi.fn().mockResolvedValue(undefined),
view: mockView
});
Reflect.set(plugin.app.workspace, 'getLeavesOfType', vi.fn().mockReturnValue([mockLeaf]));
const getBacklinkView = privateMethod(plugin, 'getBacklinkView');
const view = await getBacklinkView();
expect(mockLeaf.loadIfDeferred).toHaveBeenCalled();
expect(view).toBe(mockView);
});
});
describe('refreshBacklinkPanels', () => {
it('should call reloadBacklinksView', async () => {
const { plugin } = createTestPlugin();
const reloadBacklinksView = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'reloadBacklinksView', reloadBacklinksView);
const refreshBacklinkPanels = privateMethod(plugin, 'refreshBacklinkPanels');
await refreshBacklinkPanels();
expect(reloadBacklinksView).toHaveBeenCalled();
});
});
describe('reloadBacklinksView', () => {
it('should do nothing when no backlink view exists', async () => {
const { plugin } = createTestPlugin();
const getBacklinkView = vi.fn().mockResolvedValue(null);
Reflect.set(plugin, 'getBacklinkView', getBacklinkView);
const reloadBacklinksView = privateMethod(plugin, 'reloadBacklinksView');
await reloadBacklinksView();
expect(getBacklinkView).toHaveBeenCalled();
});
it('should not recompute when backlink view has no file', async () => {
const { plugin } = createTestPlugin();
const recomputeBacklink = vi.fn();
const backlinkView = mockProxy<BacklinkView>({
backlink: mockProxy({ recomputeBacklink }),
file: null
});
Reflect.set(plugin, 'getBacklinkView', vi.fn().mockResolvedValue(backlinkView));
const reloadBacklinksView = privateMethod(plugin, 'reloadBacklinksView');
await reloadBacklinksView();
expect(recomputeBacklink).not.toHaveBeenCalled();
});
it('should recompute when backlink view has a file', async () => {
const { plugin } = createTestPlugin();
const mockFile = createMockFile('note.md');
const recomputeBacklink = vi.fn();
const backlinkView = mockProxy<BacklinkView>({
backlink: mockProxy({ recomputeBacklink }),
file: mockFile
});
Reflect.set(plugin, 'getBacklinkView', vi.fn().mockResolvedValue(backlinkView));
const reloadBacklinksView = privateMethod(plugin, 'reloadBacklinksView');
await reloadBacklinksView();
expect(recomputeBacklink).toHaveBeenCalledWith(mockFile);
});
});
describe('onBacklinksCorePluginEnable', () => {
it('should invoke patchBacklinksPane asynchronously', () => {
const { plugin } = createTestPlugin();
const patchBacklinksPane = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'patchBacklinksPane', patchBacklinksPane);
const onBacklinksCorePluginEnable = privateMethod(plugin, 'onBacklinksCorePluginEnable');
onBacklinksCorePluginEnable();
expect(patchBacklinksPane).toHaveBeenCalled();
});
});
describe('patchBacklinksPane', () => {
it('should return early when no backlink view exists', async () => {
const { plugin } = createTestPlugin();
const getBacklinkView = vi.fn().mockResolvedValue(null);
Reflect.set(plugin, 'getBacklinkView', getBacklinkView);
const patchBacklinksPane = privateMethod(plugin, 'patchBacklinksPane');
await patchBacklinksPane();
expect(getBacklinkView).toHaveBeenCalled();
});
it('should patch addResult on backlinkDom when view exists', async () => {
const { plugin } = createTestPlugin();
markPluginLoaded(plugin);
const originalAddResult = vi.fn().mockImplementation(() => createMockResultDomItem(true));
const backlinkDomProto = { addResult: originalAddResult };
const backlinkView = mockProxy<BacklinkView>({
backlink: mockProxy({
backlinkDom: Object.create(backlinkDomProto) as object
})
});
Reflect.set(plugin, 'getBacklinkView', vi.fn().mockResolvedValue(backlinkView));
const patchBacklinksPane = privateMethod(plugin, 'patchBacklinksPane');
await patchBacklinksPane();
// The patchedAddResult is now on the prototype; call it to exercise the callback
const file = createMockFile('folder/note.md');
const result = mockProxy<ResultDomResult>({});
const mockResultDom = Object.create(backlinkDomProto) as object;
const patchedAddResult = backlinkDomProto.addResult as (
file: TFile,
result: ResultDomResult,
content: string,
shouldShowTitle?: boolean
) => ResultDomItem;
const item = patchedAddResult.call(mockResultDom, file, result, 'content', true);
expect(item).toBeDefined();
});
});
describe('refreshBacklinkPanels with markdown leaves', () => {
it('should skip leaves that are not MarkdownView instances', async () => {
const { plugin } = createTestPlugin();
Reflect.set(plugin, 'reloadBacklinksView', vi.fn().mockResolvedValue(undefined));
const nonMarkdownLeaf = mockProxy<WorkspaceLeaf>({
view: mockProxy({})
});
Reflect.set(
plugin.app.workspace,
'getLeavesOfType',
vi.fn().mockImplementation((type: string) => {
if (type === 'markdown') {
return [nonMarkdownLeaf];
}
return [];
})
);
const refreshBacklinkPanels = privateMethod(plugin, 'refreshBacklinkPanels');
await refreshBacklinkPanels();
});
it('should skip MarkdownView leaves without backlinks', async () => {
const { app, plugin } = createTestPlugin();
Reflect.set(plugin, 'reloadBacklinksView', vi.fn().mockResolvedValue(undefined));
const leaf = WorkspaceLeaf.create2__(app);
const mdView = MarkdownView.create2__(leaf);
Reflect.set(bypassStrictProxy(mdView), 'backlinks', undefined);
Reflect.set(bypassStrictProxy(leaf), 'view', mdView);
Reflect.set(
plugin.app.workspace,
'getLeavesOfType',
vi.fn().mockImplementation((type: string) => {
if (type === 'markdown') {
return [leaf.asOriginalType__()];
}
return [];
})
);
const refreshBacklinkPanels = privateMethod(plugin, 'refreshBacklinkPanels');
await refreshBacklinkPanels();
});
it('should recompute backlinks for MarkdownView leaves with backlinks', async () => {
const { app, plugin } = createTestPlugin();
Reflect.set(plugin, 'reloadBacklinksView', vi.fn().mockResolvedValue(undefined));
const leaf = WorkspaceLeaf.create2__(app);
const mdView = MarkdownView.create2__(leaf);
const mockFile = createMockFile('test2.md');
const recomputeBacklink = vi.fn();
Reflect.set(bypassStrictProxy(mdView), 'backlinks', { file: mockFile, recomputeBacklink });
Reflect.set(bypassStrictProxy(leaf), 'view', mdView);
Reflect.set(
plugin.app.workspace,
'getLeavesOfType',
vi.fn().mockImplementation((type: string) => {
if (type === 'markdown') {
return [leaf.asOriginalType__()];
}
return [];
})
);
const refreshBacklinkPanels = privateMethod(plugin, 'refreshBacklinkPanels');
await refreshBacklinkPanels();
expect(recomputeBacklink).toHaveBeenCalledWith(mockFile);
});
it('should wire up all components in onloadImpl', () => {
const app = createMockApp();
const plugin = new Plugin(app, createMockManifest());
const addChildSpy = vi.spyOn(plugin, 'addChild');
castTo<PluginInternals>(plugin).onloadImpl();
expect(PluginSettingsComponent).toHaveBeenCalledOnce();
expect(PluginSettingsTab).toHaveBeenCalledOnce();
expect(BacklinkFullPathComponent).toHaveBeenCalledOnce();
const EXPECTED_ADD_CHILD_COUNT = 3;
expect(addChildSpy).toHaveBeenCalledTimes(EXPECTED_ADD_CHILD_COUNT);
});
});