From fcf4e4e24271a9d06344c8ed1a11b1fcb5203823 Mon Sep 17 00:00:00 2001 From: Michael Naumov Date: Thu, 18 Jun 2026 21:07:14 -0600 Subject: [PATCH] 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. --- src/backlink-full-path-component.test.ts | 565 +++++++++++++++++ src/plugin-settings-component.test.ts | 26 + src/plugin-settings-tab.test.ts | 168 ++--- src/plugin.test.ts | 752 ++--------------------- 4 files changed, 720 insertions(+), 791 deletions(-) create mode 100644 src/backlink-full-path-component.test.ts create mode 100644 src/plugin-settings-component.test.ts diff --git a/src/backlink-full-path-component.test.ts b/src/backlink-full-path-component.test.ts new file mode 100644 index 0000000..78da381 --- /dev/null +++ b/src/backlink-full-path-component.test.ts @@ -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; + onBacklinksCorePluginEnable(): void; + onLayoutReady(): Promise; + patchBacklinksPane(): Promise; + refreshBacklinkPanels(): Promise; + reloadBacklinksView(): Promise; +} + +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; + markdownLeaves: WorkspaceLeaf[]; + on: ReturnType; + registeredCallbacks: (() => void)[]; + settings: PluginSettings; +} + +vi.mock('obsidian-dev-utils/async', () => ({ + invokeAsyncSafely: vi.fn((fn: () => Promise) => 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(component); +} + +function createCorePlugin(enabled: boolean, instanceProto: object = {}): CorePlugin { + return { + enabled, + instance: Object.create(instanceProto) + }; +} + +function createMarkdownLeaf(backlinks: unknown): WorkspaceLeaf { + return castTo({ + 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({ + 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({ el }); +} + +function createTestContext(): TestContext { + const settings = new PluginSettings(); + const markdownLeaves: WorkspaceLeaf[] = []; + const getPluginById = vi.fn(); + const on = vi.fn(); + + const app = strictProxy({ + internalPlugins: { + getPluginById + }, + workspace: { + getLeavesOfType: vi.fn().mockImplementation((type: string) => { + if (type === ViewType.Markdown) { + return markdownLeaves; + } + return []; + }) + } + }); + + const pluginSettingsComponent = strictProxy({ + on, + settings + }); + + const component = new BacklinkFullPathComponent({ + app, + pluginSettingsComponent + }); + + const registeredCallbacks = castTo(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; + 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({}); + const loadIfDeferred = vi.fn().mockResolvedValue(undefined); + const mockLeaf = castTo({ + 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({ + 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({}); + 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({ + 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({}); + 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({ + 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({ + 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({ 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); + }); + }); +}); diff --git a/src/plugin-settings-component.test.ts b/src/plugin-settings-component.test.ts new file mode 100644 index 0000000..8cbe0dc --- /dev/null +++ b/src/plugin-settings-component.test.ts @@ -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({}); + const pluginEventSource = strictProxy({}); + + const component = new PluginSettingsComponent({ + dataHandler, + pluginEventSource + }); + + expect(component.settings).toBeInstanceOf(PluginSettings); + }); +}); diff --git a/src/plugin-settings-tab.test.ts b/src/plugin-settings-tab.test.ts index a06c3ac..bffd292 100644 --- a/src/plugin-settings-tab.test.ts +++ b/src/plugin-settings-tab.test.ts @@ -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({ + pathDepth: 0, + rootPaths: [], + shouldDisplayParentPathOnSeparateLine: false, + shouldHighlightFileName: true, + shouldIncludeExtension: true, + shouldReversePathParts: false, + shouldShowEllipsisForSkippedPathParts: true + }); + + const pluginSettingsComponent = strictProxy>({ + on: vi.fn().mockReturnValue({ id: 'ref' }), + settings + }); + + const plugin = strictProxy({ + 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' + ]); }); }); diff --git a/src/plugin.test.ts b/src/plugin.test.ts index 01f0d0a..d157bab 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -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; - 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({ - 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({ 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({}); +} + +function createMockManifest(): PluginManifest { + return strictProxy({ 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 { - 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. -function mockProxy(partial: object): T { - return strictProxy(partial as StrictProxyPartial); -} - -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) | 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({ - 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({ - 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({ - 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({ - 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({}); - const result = mockProxy({}); - - 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({}); - const result = mockProxy({}); - - 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({}); - const mockLeaf = mockProxy({ - 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({ - 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({ - 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({ - 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({}); - 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({ - 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(plugin).onloadImpl(); + + expect(PluginSettingsComponent).toHaveBeenCalledOnce(); + expect(PluginSettingsTab).toHaveBeenCalledOnce(); + expect(BacklinkFullPathComponent).toHaveBeenCalledOnce(); + const EXPECTED_ADD_CHILD_COUNT = 3; + expect(addChildSpy).toHaveBeenCalledTimes(EXPECTED_ADD_CHILD_COUNT); }); });