mirror of
https://github.com/mnaoumov/obsidian-backlink-full-path.git
synced 2026-07-22 05:41:39 +00:00
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:
parent
6f0f2b8d4e
commit
fcf4e4e242
4 changed files with 720 additions and 791 deletions
565
src/backlink-full-path-component.test.ts
Normal file
565
src/backlink-full-path-component.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/plugin-settings-component.test.ts
Normal file
26
src/plugin-settings-component.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue