From b3f3c329bfef60ea905c20a0c0bfa94201d3e4e9 Mon Sep 17 00:00:00 2001 From: Michael Naumov Date: Sat, 20 Jun 2026 21:39:43 -0600 Subject: [PATCH] test: restore component tests to real dev-utils base classes Rewrite backlink-full-path-component.test.ts to drive the real LayoutReadyComponent lifecycle and add real-MonkeyAroundComponent tests for the extracted patch components, replacing the stale, mock-based suite left behind by the monkey-around refactor. Restores 100% coverage. --- src/backlink-full-path-component.test.ts | 686 ++++++------------ ...nce-on-user-enable-patch-component.test.ts | 75 ++ ...ult-dom-add-result-patch-component.test.ts | 231 ++++++ 3 files changed, 530 insertions(+), 462 deletions(-) create mode 100644 src/patches/backlink-plugin-instance-on-user-enable-patch-component.test.ts create mode 100644 src/patches/result-dom-add-result-patch-component.test.ts diff --git a/src/backlink-full-path-component.test.ts b/src/backlink-full-path-component.test.ts index 78da381..f9f297c 100644 --- a/src/backlink-full-path-component.test.ts +++ b/src/backlink-full-path-component.test.ts @@ -1,19 +1,17 @@ -import type { - BacklinkView, - ResultDomItem, - ResultDomResult -} from '@obsidian-typings/obsidian-public-latest'; +import type { BacklinkView } from '@obsidian-typings/obsidian-public-latest'; import type { App, TFile, WorkspaceLeaf } from 'obsidian'; +import type { Mock } from 'vitest'; 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 { + afterEach, beforeEach, describe, expect, @@ -24,17 +22,11 @@ import { import type { PluginSettingsComponent } from './plugin-settings-component.ts'; import { BacklinkFullPathComponent } from './backlink-full-path-component.ts'; +import { ResultDomAddResultPatchComponent } from './patches/result-dom-add-result-patch-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; @@ -45,86 +37,232 @@ interface CorePlugin { 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 PushBacklinkViewParams { + readonly file: null | TFile; + readonly recomputeBacklink: Mock<(backlinkFile: null | TFile) => void>; } interface TestContext { app: App; + backlinkLeaves: WorkspaceLeaf[]; component: BacklinkFullPathComponent; getPluginById: ReturnType; markdownLeaves: WorkspaceLeaf[]; on: ReturnType; - registeredCallbacks: (() => void)[]; - settings: PluginSettings; } -vi.mock('obsidian-dev-utils/async', () => ({ - invokeAsyncSafely: vi.fn((fn: () => Promise) => fn()) -})); +describe('BacklinkFullPathComponent', () => { + let context: TestContext; -vi.mock('obsidian-dev-utils/obsidian/components/layout-ready-component', () => ({ - LayoutReadyComponent: class MockLayoutReadyComponent { - public app: App; - public readonly registeredCallbacks: (() => void)[] = []; + beforeEach(() => { + context = createTestContext(); + }); - public constructor(app: App) { - this.app = app; - } + afterEach(() => { + vi.useRealTimers(); + }); - public addChild(child: unknown): unknown { - return child; - } + describe('onLayoutReady', () => { + it('should register a saveSettings handler that refreshes panels', async () => { + context.getPluginById.mockReturnValue(undefined); + const refreshSpy = vi.spyOn(internals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined); - public register(callback: () => void): void { - this.registeredCallbacks.push(callback); - } + await triggerLayoutReady(); + + expect(context.on).toHaveBeenCalledWith('saveSettings', expect.any(Function)); + const handler = castTo<() => Promise>(context.on.mock.calls[0]?.[1]); + await handler(); + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('should not patch the pane when the backlinks core plugin is not found', async () => { + context.getPluginById.mockReturnValue(undefined); + const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined); + + await triggerLayoutReady(); + + expect(patchSpy).not.toHaveBeenCalled(); + }); + + it('should add the onUserEnable patch child when the core plugin is found', async () => { + context.getPluginById.mockReturnValue(createCorePlugin(false)); + vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined); + const addChildSpy = vi.spyOn(context.component, 'addChild'); + + await triggerLayoutReady(); + + expect(addChildSpy).toHaveBeenCalled(); + }); + + it('should patch the pane and refresh panels when the plugin is enabled', async () => { + context.getPluginById.mockReturnValue(createCorePlugin(true)); + const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined); + const refreshSpy = vi.spyOn(internals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined); + + await triggerLayoutReady(); + + expect(patchSpy).toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('should not patch the pane when the plugin is disabled', async () => { + context.getPluginById.mockReturnValue(createCorePlugin(false)); + const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined); + + await triggerLayoutReady(); + + expect(patchSpy).not.toHaveBeenCalled(); + }); + + it('should register an unload callback that refreshes panels', async () => { + context.getPluginById.mockReturnValue(createCorePlugin(false)); + const refreshSpy = vi.spyOn(internals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined); + const registerSpy = vi.spyOn(context.component, 'register'); + + await triggerLayoutReady(); + + const unloadCallback = registerSpy.mock.calls.at(-1)?.[0]; + unloadCallback?.(); + + expect(refreshSpy).toHaveBeenCalled(); + }); + }); + + describe('onBacklinksCorePluginEnable', () => { + it('should patch the backlinks pane', () => { + const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined); + + context.component.onBacklinksCorePluginEnable(); + + expect(patchSpy).toHaveBeenCalled(); + }); + }); + + describe('getBacklinkView', () => { + it('should return null when no backlink leaf exists', async () => { + const view = await internals(context.component).getBacklinkView(); + expect(view).toBeNull(); + }); + + it('should load the deferred leaf and return its view', async () => { + const backlinkView = strictProxy({}); + const loadIfDeferred = vi.fn().mockResolvedValue(undefined); + context.backlinkLeaves.push(castTo({ + loadIfDeferred, + view: backlinkView + })); + + const view = await internals(context.component).getBacklinkView(); + + expect(loadIfDeferred).toHaveBeenCalled(); + expect(view).toBe(backlinkView); + }); + }); + + describe('patchBacklinksPane', () => { + it('should do nothing when no backlink view exists', async () => { + const addChildSpy = vi.spyOn(context.component, 'addChild'); + + await internals(context.component).patchBacklinksPane(); + + expect(addChildSpy).not.toHaveBeenCalled(); + }); + + it('should add a result-dom patch child when a backlink view exists', async () => { + const backlinkDomProto = { addResult: vi.fn() }; + const backlinkView = strictProxy({ + backlink: strictProxy({ + backlinkDom: Object.create(backlinkDomProto) + }) + }); + context.backlinkLeaves.push(castTo({ + loadIfDeferred: vi.fn().mockResolvedValue(undefined), + view: backlinkView + })); + const addChildSpy = vi.spyOn(context.component, 'addChild'); + + await internals(context.component).patchBacklinksPane(); + + expect(addChildSpy).toHaveBeenCalledWith(expect.any(ResultDomAddResultPatchComponent)); + }); + }); + + describe('reloadBacklinksView', () => { + it('should do nothing when no backlink view exists', async () => { + await expect(internals(context.component).reloadBacklinksView()).resolves.toBeUndefined(); + }); + + it('should not recompute when the backlink view has no file', async () => { + const recomputeBacklink = vi.fn<(backlinkFile: null | TFile) => void>(); + pushBacklinkView({ file: null, recomputeBacklink }); + + await internals(context.component).reloadBacklinksView(); + + expect(recomputeBacklink).not.toHaveBeenCalled(); + }); + + it('should recompute when the backlink view has a file', async () => { + const file = createMockFile('note.md'); + const recomputeBacklink = vi.fn<(backlinkFile: null | TFile) => void>(); + pushBacklinkView({ file, recomputeBacklink }); + + await internals(context.component).reloadBacklinksView(); + + expect(recomputeBacklink).toHaveBeenCalledWith(file); + }); + }); + + describe('refreshBacklinkPanels', () => { + beforeEach(() => { + vi.spyOn(internals(context.component), 'reloadBacklinksView').mockResolvedValue(undefined); + }); + + it('should skip leaves that are not MarkdownView instances', async () => { + context.markdownLeaves.push(castTo({ view: {} })); + + await expect(internals(context.component).refreshBacklinkPanels()).resolves.toBeUndefined(); + }); + + it('should skip MarkdownView leaves without backlinks', async () => { + context.markdownLeaves.push(createMarkdownLeaf(undefined)); + + await expect(internals(context.component).refreshBacklinkPanels()).resolves.toBeUndefined(); + }); + + it('should recompute backlinks for MarkdownView leaves with backlinks', async () => { + const file = createMockFile('note.md'); + const recomputeBacklink = vi.fn(); + context.markdownLeaves.push(createMarkdownLeaf({ file, recomputeBacklink })); + + await internals(context.component).refreshBacklinkPanels(); + + expect(recomputeBacklink).toHaveBeenCalledWith(file); + }); + }); + + function pushBacklinkView(backlink: PushBacklinkViewParams): void { + const backlinkView = strictProxy({ + backlink: strictProxy({ recomputeBacklink: backlink.recomputeBacklink }), + file: backlink.file + }); + context.backlinkLeaves.push(castTo({ + loadIfDeferred: vi.fn().mockResolvedValue(undefined), + view: backlinkView + })); } -})); -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 - }); - }; - } + async function triggerLayoutReady(): Promise { + vi.useFakeTimers(); + context.component.load(); + await vi.runAllTimersAsync(); + vi.useRealTimers(); } -})); +}); -function asInternals(component: BacklinkFullPathComponent): ComponentInternals { - return castTo(component); -} - -function createCorePlugin(enabled: boolean, instanceProto: object = {}): CorePlugin { +function createCorePlugin(enabled: boolean): CorePlugin { return { enabled, - instance: Object.create(instanceProto) + instance: Object.create({ onUserEnable: vi.fn() }) }; } @@ -135,32 +273,11 @@ function createMarkdownLeaf(backlinks: unknown): WorkspaceLeaf { } 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 }); + return strictProxy({ path }); } function createTestContext(): TestContext { - const settings = new PluginSettings(); + const backlinkLeaves: WorkspaceLeaf[] = []; const markdownLeaves: WorkspaceLeaf[] = []; const getPluginById = vi.fn(); const on = vi.fn(); @@ -171,17 +288,23 @@ function createTestContext(): TestContext { }, workspace: { getLeavesOfType: vi.fn().mockImplementation((type: string) => { + if (type === ViewType.Backlink) { + return backlinkLeaves; + } if (type === ViewType.Markdown) { return markdownLeaves; } return []; + }), + onLayoutReady: vi.fn().mockImplementation((callback: () => void) => { + callback(); }) } }); const pluginSettingsComponent = strictProxy({ on, - settings + settings: new PluginSettings() }); const component = new BacklinkFullPathComponent({ @@ -189,377 +312,16 @@ function createTestContext(): TestContext { pluginSettingsComponent }); - const registeredCallbacks = castTo(component).registeredCallbacks; - return { app, + backlinkLeaves, component, getPluginById, markdownLeaves, - on, - registeredCallbacks, - settings + on }; } -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); - }); - }); -}); +function internals(component: BacklinkFullPathComponent): ComponentInternals { + return castTo(component); +} diff --git a/src/patches/backlink-plugin-instance-on-user-enable-patch-component.test.ts b/src/patches/backlink-plugin-instance-on-user-enable-patch-component.test.ts new file mode 100644 index 0000000..e168403 --- /dev/null +++ b/src/patches/backlink-plugin-instance-on-user-enable-patch-component.test.ts @@ -0,0 +1,75 @@ +import type { BacklinkPluginInstance } from '@obsidian-typings/obsidian-public-latest'; +import type { Mock } from 'vitest'; + +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 { BacklinkFullPathComponent } from '../backlink-full-path-component.ts'; + +import { BacklinkPluginInstanceOnUserEnablePatchComponent } from './backlink-plugin-instance-on-user-enable-patch-component.ts'; + +interface BacklinkPluginInstanceProto { + onUserEnable(): void; +} + +describe('BacklinkPluginInstanceOnUserEnablePatchComponent', () => { + let onBacklinksCorePluginEnable: Mock<() => void>; + let backlinkFullPathComponent: BacklinkFullPathComponent; + let originalOnUserEnable: Mock<() => void>; + let instanceProto: BacklinkPluginInstanceProto; + let backlinkPluginInstance: BacklinkPluginInstance; + + beforeEach(() => { + onBacklinksCorePluginEnable = vi.fn<() => void>(); + backlinkFullPathComponent = strictProxy({ + onBacklinksCorePluginEnable + }); + originalOnUserEnable = vi.fn<() => void>(); + instanceProto = { onUserEnable: originalOnUserEnable }; + backlinkPluginInstance = castTo(Object.create(instanceProto)); + }); + + function createComponent(): BacklinkPluginInstanceOnUserEnablePatchComponent { + return new BacklinkPluginInstanceOnUserEnablePatchComponent({ + backlinkFullPathComponent, + backlinkPluginInstance + }); + } + + it('should register a single method patch on load', () => { + const component = createComponent(); + const registerMethodPatchSpy = vi.spyOn(component, 'registerMethodPatch'); + + component.load(); + + expect(registerMethodPatchSpy).toHaveBeenCalledTimes(1); + }); + + it('should call the original onUserEnable and notify the component when the patched method runs', () => { + const component = createComponent(); + component.load(); + + instanceProto.onUserEnable.call(backlinkPluginInstance); + + expect(originalOnUserEnable).toHaveBeenCalled(); + expect(onBacklinksCorePluginEnable).toHaveBeenCalled(); + }); + + it('should remove the patch on unload', () => { + const component = createComponent(); + component.load(); + component.unload(); + + instanceProto.onUserEnable.call(backlinkPluginInstance); + + expect(originalOnUserEnable).toHaveBeenCalled(); + expect(onBacklinksCorePluginEnable).not.toHaveBeenCalled(); + }); +}); diff --git a/src/patches/result-dom-add-result-patch-component.test.ts b/src/patches/result-dom-add-result-patch-component.test.ts new file mode 100644 index 0000000..5dc97a1 --- /dev/null +++ b/src/patches/result-dom-add-result-patch-component.test.ts @@ -0,0 +1,231 @@ +import type { + ResultDom, + ResultDomItem, + ResultDomResult +} from '@obsidian-typings/obsidian-public-latest'; +import type { TFile } 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 { PluginSettings } from '../plugin-settings.ts'; +import { ResultDomAddResultPatchComponent } from './result-dom-add-result-patch-component.ts'; + +interface ResultDomProto { + addResult(file: TFile, result: ResultDomResult, content: string, shouldShowTitle: boolean): ResultDomItem; +} + +describe('ResultDomAddResultPatchComponent', () => { + let settings: PluginSettings; + let pluginSettingsComponent: PluginSettingsComponent; + let resultDomProto: ResultDomProto; + let resultDom: ResultDom; + + beforeEach(() => { + settings = new PluginSettings(); + pluginSettingsComponent = strictProxy({ + settings + }); + resultDomProto = { + addResult: vi.fn().mockImplementation(() => createOriginalItem(true)) + }; + resultDom = castTo(Object.create(resultDomProto)); + }); + + it('should register a single method patch on load', () => { + const component = createComponent(); + const registerMethodPatchSpy = vi.spyOn(component, 'registerMethodPatch'); + + component.load(); + + expect(registerMethodPatchSpy).toHaveBeenCalledTimes(1); + }); + + it('should leave the result untouched when there is no tree-item-inner', () => { + resultDomProto.addResult = vi.fn().mockImplementation(() => createOriginalItem(false)); + const component = createComponent(); + component.load(); + + const item = resultDomProto.addResult(createMockFile('note.md'), strictProxy({}), 'content', true); + + expect(item.el.querySelector('.tree-item-inner')).toBeNull(); + }); + + it('should remove the patch on unload', () => { + const component = createComponent(); + component.load(); + component.unload(); + + const item = resultDomProto.addResult(createMockFile('note.md'), strictProxy({}), 'content', true); + + expect(item.el.querySelector('.tree-item-inner')?.textContent).toBe('Original'); + }); + + describe('generated backlink title', () => { + it('should replace the tree-item-inner content with the generated title', () => { + const container = generateContainer('folder/note.md'); + expect(container.classList.contains('backlink-full-path')).toBe(true); + }); + + it('should include the extension when shouldIncludeExtension is true', () => { + settings.shouldIncludeExtension = true; + const container = generateContainer('folder/note.md'); + expect(getPart(container, 'file-name')?.textContent).toBe('note.md'); + }); + + it('should exclude the extension when shouldIncludeExtension is false', () => { + settings.shouldIncludeExtension = false; + const container = generateContainer('folder/note.md'); + expect(getPart(container, 'file-name')?.textContent).toBe('note'); + }); + + it('should show no parent path for a file at the root', () => { + const container = generateContainer('note.md'); + expect(getPart(container, 'parent-path')).toBeNull(); + }); + + it('should show the parent path with a trailing separator', () => { + settings.shouldReversePathParts = false; + settings.shouldDisplayParentPathOnSeparateLine = false; + const container = generateContainer('folder/note.md'); + expect(getPart(container, 'parent-path')?.textContent).toBe('folder/'); + }); + + it('should strip a root path prefix', () => { + settings.rootPaths = ['folder']; + const container = generateContainer('folder/subfolder/note.md'); + expect(getPart(container, 'parent-path')?.textContent).toBe('subfolder/'); + }); + + it('should strip a nested root path prefix', () => { + settings.rootPaths = ['folder/subfolder']; + const container = generateContainer('folder/subfolder/deep/note.md'); + expect(getPart(container, 'parent-path')?.textContent).toBe('deep/'); + }); + + it('should limit depth with the pathDepth setting', () => { + settings.pathDepth = 2; + settings.shouldShowEllipsisForSkippedPathParts = false; + const container = generateContainer('a/b/c/note.md'); + expect(getPart(container, 'parent-path')?.textContent).toBe('c/'); + }); + + it('should add an ellipsis for skipped parts when enabled', () => { + settings.pathDepth = 2; + settings.shouldShowEllipsisForSkippedPathParts = true; + const container = generateContainer('a/b/c/note.md'); + expect(getPart(container, 'parent-path')?.textContent).toBe('.../c/'); + }); + + it('should not add an ellipsis when pathDepth does not truncate', () => { + settings.pathDepth = 5; + settings.shouldShowEllipsisForSkippedPathParts = true; + const container = generateContainer('a/b/note.md'); + expect(getPart(container, 'parent-path')?.textContent).toBe('a/b/'); + }); + + it('should reverse the path parts when shouldReversePathParts is true', () => { + settings.shouldReversePathParts = true; + settings.shouldDisplayParentPathOnSeparateLine = false; + const container = generateContainer('a/b/note.md'); + expect(getPart(container, 'parent-path')?.textContent).toBe(' ← b ← a'); + }); + + it('should display the parent path on a separate line without a separator', () => { + settings.shouldDisplayParentPathOnSeparateLine = true; + settings.shouldReversePathParts = false; + const container = generateContainer('folder/note.md'); + expect(getPart(container, 'parent-path')?.textContent).toBe('folder'); + }); + + it('should set dataset attributes for highlighting', () => { + settings.shouldHighlightFileName = true; + settings.shouldDisplayParentPathOnSeparateLine = false; + const container = generateContainer('note.md'); + expect(container.dataset['shouldHighlightFileName']).toBe('true'); + expect(container.dataset['shouldDisplayParentPathOnSeparateLine']).toBe('false'); + }); + + it('should include a full-path span with the file path', () => { + const container = generateContainer('folder/note.md'); + expect(container.querySelector('.full-path')?.textContent).toBe('folder/note.md'); + }); + + it('should prepend the parent path when not reversed and not on a separate line', () => { + settings.shouldReversePathParts = false; + settings.shouldDisplayParentPathOnSeparateLine = false; + const container = generateContainer('folder/note.md'); + expect(container.shadowRoot?.firstElementChild?.getAttribute('part')).toBe('parent-path'); + }); + + it('should append the parent path when reversed and not on a separate line', () => { + settings.shouldReversePathParts = true; + settings.shouldDisplayParentPathOnSeparateLine = false; + const container = generateContainer('folder/note.md'); + expect(container.shadowRoot?.lastElementChild?.getAttribute('part')).toBe('parent-path'); + }); + + it('should append the parent path when displaying on a separate line', () => { + settings.shouldReversePathParts = false; + settings.shouldDisplayParentPathOnSeparateLine = true; + const container = generateContainer('folder/note.md'); + expect(container.shadowRoot?.lastElementChild?.getAttribute('part')).toBe('parent-path'); + }); + }); + + function createComponent(): ResultDomAddResultPatchComponent { + return new ResultDomAddResultPatchComponent({ + pluginSettingsComponent, + resultDom + }); + } + + function generateContainer(filePath: string): HTMLDivElement { + const component = createComponent(); + component.load(); + + const item = resultDomProto.addResult(createMockFile(filePath), strictProxy({}), 'content', true); + const inner = item.el.querySelector('.tree-item-inner'); + const container = inner?.firstElementChild; + if (!(container instanceof HTMLDivElement)) { + throw new Error('Expected a generated backlink-full-path container'); + } + return container; + } +}); + +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 createOriginalItem(hasTreeItemInner: boolean): ResultDomItem { + const el = createDiv(); + if (hasTreeItemInner) { + const inner = el.createDiv({ cls: 'tree-item-inner' }); + inner.textContent = 'Original'; + } + return strictProxy({ el }); +} + +function getPart(container: HTMLDivElement, part: string): Element | null { + return container.shadowRoot?.querySelector(`[part="${part}"]`) ?? null; +}