diff --git a/cspell.json b/cspell.json index c570471..f58d3e2 100644 --- a/cspell.json +++ b/cspell.json @@ -11,6 +11,7 @@ "words": [ "mnaoumov", "Naumov", + "offref", "tsbuildinfo" ], "ignoreWords": [], diff --git a/src/command-handlers/invoke-command-handler.test.ts b/src/command-handlers/invoke-command-handler.test.ts index 80a3ccd..0c2b1b8 100644 --- a/src/command-handlers/invoke-command-handler.test.ts +++ b/src/command-handlers/invoke-command-handler.test.ts @@ -7,6 +7,7 @@ import type { ReadonlyDeep } from 'type-fest'; import { strictProxy } from 'obsidian-dev-utils/strict-proxy'; import { + beforeEach, describe, expect, it, @@ -17,16 +18,6 @@ import type { PluginSettings } from '../plugin-settings.ts'; import { InvokeCommandHandler } from './invoke-command-handler.ts'; -interface InternalExecuteEditor { - executeEditor(editor: Editor, ctx: MarkdownFileInfo): void; -} - -interface MockEditorCommandHandlerConstructorParams { - readonly icon: string; - readonly id: string; - readonly name: string; -} - const hoisted = vi.hoisted(() => ({ mockInsertAttachmentsControlConstructor: vi.fn() })); @@ -35,80 +26,68 @@ vi.mock('../insert-attachments-control.ts', () => ({ InsertAttachmentsControl: hoisted.mockInsertAttachmentsControlConstructor })); -vi.mock('obsidian-dev-utils/obsidian/command-handlers/editor-command-handler', () => ({ - EditorCommandHandler: class MockEditorCommandHandler { - public readonly icon: string; - public readonly id: string; - public readonly name: string; - - public constructor(params: MockEditorCommandHandlerConstructorParams) { - this.icon = params.icon; - this.id = params.id; - this.name = params.name; - } - } -})); +function createHandler(app?: App, settings?: ReadonlyDeep): InvokeCommandHandler { + return new InvokeCommandHandler({ + app: app ?? strictProxy({}), + getPluginSettings: (): ReadonlyDeep => settings ?? strictProxy({}), + pluginName: 'test-plugin' + }); +} describe('InvokeCommandHandler', () => { - it('should create an instance', (): void => { - const handler = new InvokeCommandHandler({ - app: strictProxy({}), - getPluginSettings: (): ReadonlyDeep => strictProxy({}), - pluginName: 'test-plugin' - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - expect(handler).toBeInstanceOf(InvokeCommandHandler); + it('should create an instance', (): void => { + expect(createHandler()).toBeInstanceOf(InvokeCommandHandler); }); it('should set correct id', (): void => { - const handler = new InvokeCommandHandler({ - app: strictProxy({}), - getPluginSettings: (): ReadonlyDeep => strictProxy({}), - pluginName: 'test-plugin' - }); - - expect(handler.id).toBe('invoke'); + expect(createHandler().id).toBe('invoke'); }); it('should set correct icon', (): void => { - const handler = new InvokeCommandHandler({ - app: strictProxy({}), - getPluginSettings: (): ReadonlyDeep => strictProxy({}), - pluginName: 'test-plugin' - }); - - expect(handler.icon).toBe('lucide-paperclip'); + expect(createHandler().icon).toBe('lucide-paperclip'); }); it('should set correct name', (): void => { - const handler = new InvokeCommandHandler({ - app: strictProxy({}), - getPluginSettings: (): ReadonlyDeep => strictProxy({}), - pluginName: 'test-plugin' - }); - - expect(handler.name).toBe('Invoke'); + expect(createHandler().name).toBe('Invoke'); }); - it('should create InsertAttachmentsControl when executeEditor is called', (): void => { - const mockApp = strictProxy({}); - const mockSettings = strictProxy({}); - const handler = new InvokeCommandHandler({ - app: mockApp, - getPluginSettings: (): ReadonlyDeep => mockSettings, - pluginName: 'test-plugin' - }); + it('should build a command with an editorCheckCallback', (): void => { + const command = createHandler().buildCommand(); - const mockEditor = strictProxy({}); - const mockCtx = strictProxy({}); + expect(command.id).toBe('invoke'); + expect(command.editorCheckCallback).toBeDefined(); + }); - // eslint-disable-next-line no-restricted-syntax -- test helper accesses protected executeEditor method. - (handler as unknown as InternalExecuteEditor).executeEditor(mockEditor, mockCtx); + it('should create InsertAttachmentsControl when the command executes', (): void => { + const app = strictProxy({}); + const settings = strictProxy({}); + const handler = createHandler(app, settings); + + const editor = strictProxy({}); + const ctx = strictProxy({}); + const command = handler.buildCommand(); + command.editorCheckCallback?.(false, editor, ctx); expect(hoisted.mockInsertAttachmentsControlConstructor).toHaveBeenCalledWith({ - app: mockApp, - editor: mockEditor, - pluginSettings: mockSettings + app, + editor, + pluginSettings: settings }); }); + + it('should not create InsertAttachmentsControl when only checking', (): void => { + const handler = createHandler(); + + const editor = strictProxy({}); + const ctx = strictProxy({}); + const command = handler.buildCommand(); + const result = command.editorCheckCallback?.(true, editor, ctx); + + expect(result).toBe(true); + expect(hoisted.mockInsertAttachmentsControlConstructor).not.toHaveBeenCalled(); + }); }); diff --git a/src/insert-attachments-control.test.ts b/src/insert-attachments-control.test.ts index 7005818..bbf1d78 100644 --- a/src/insert-attachments-control.test.ts +++ b/src/insert-attachments-control.test.ts @@ -18,21 +18,12 @@ import type { PluginSettings } from './plugin-settings.ts'; import { InsertAttachmentsControl } from './insert-attachments-control.ts'; -const hoisted = vi.hoisted(() => ({ - mockBasename: vi.fn(), - mockConvertAsyncToSync: vi.fn(), - mockExtname: vi.fn() -})); - -vi.mock('obsidian-dev-utils/async', () => ({ - convertAsyncToSync: hoisted.mockConvertAsyncToSync -})); - -vi.mock('obsidian-dev-utils/path', () => ({ - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-unsafe-return -- mock factory delegates to hoisted fn. - basename: (...args: unknown[]) => hoisted.mockBasename(...args), - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-unsafe-return -- mock factory delegates to hoisted fn. - extname: (...args: unknown[]) => hoisted.mockExtname(...args) +// The real `convertAsyncToSync` wraps the change handler as fire-and-forget, so awaiting the registered +// Listener would not await the inner async work. Stub it to identity (the sanctioned exception) so the test +// Can capture and await the real `handleChange`. `basename`/`extname` are left as the real path utilities. +vi.mock('obsidian-dev-utils/async', async (importOriginal) => ({ + ...await importOriginal(), + convertAsyncToSync: vi.fn((fn: (...args: unknown[]) => unknown) => fn) })); interface CreateControlParams { @@ -68,7 +59,6 @@ interface MockFileList { } let mockFileEl: MockFileInput; -let capturedChangeHandler: (() => Promise) | undefined; let mockDocumentAddEventListener: ReturnType; let mockDocumentRemoveEventListener: ReturnType; let mockSetTimeout: ReturnType; @@ -109,9 +99,9 @@ function createMockFileEl(): MockFileInput { files: null, focus: vi.fn(), triggerChange: async () => { - if (capturedChangeHandler) { - await capturedChangeHandler(); - } + const changeCall = el.addEventListener.mock.calls.find((call: unknown[]) => call[0] === 'change'); + const changeHandler = changeCall?.[1] as (() => Promise) | undefined; + await changeHandler?.(); } }; return el; @@ -128,7 +118,6 @@ function createMockSettings(overrides?: Partial): ReadonlyDeep

{ beforeEach(() => { - capturedChangeHandler = undefined; mockDocumentAddEventListener = vi.fn(); mockDocumentRemoveEventListener = vi.fn(); mockSetTimeout = vi.fn().mockReturnValue(42); @@ -157,20 +146,6 @@ describe('InsertAttachmentsControl', () => { configurable: true, value: mockClearTimeout }); - - hoisted.mockConvertAsyncToSync.mockImplementation((fn: () => Promise) => { - capturedChangeHandler = fn; - return fn; - }); - - hoisted.mockBasename.mockImplementation((name: string, ext?: string) => { - if (ext) { - return name.replace(ext, ''); - } - return name; - }); - - hoisted.mockExtname.mockReturnValue('.png'); }); afterEach(() => { diff --git a/src/main.test.ts b/src/main.test.ts index 3ce638c..2d1691b 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,78 +1,10 @@ import { describe, expect, - it, - vi + it } from 'vitest'; -vi.mock('obsidian-dev-utils/obsidian/active-file-provider', () => ({ - AppActiveFileProvider: vi.fn() -})); - -vi.mock('obsidian-dev-utils/obsidian/command-handlers/command-handler-component', () => ({ - CommandHandlerComponent: vi.fn() -})); - -vi.mock('obsidian-dev-utils/obsidian/command-registrar', () => ({ - PluginCommandRegistrar: vi.fn() -})); - -vi.mock('obsidian-dev-utils/obsidian/components/layout-ready-component', () => ({ - CallbackLayoutReadyComponent: vi.fn() -})); - -vi.mock('obsidian-dev-utils/obsidian/components/menu-event-registrar-component', () => ({ - MenuEventRegistrarComponent: vi.fn() -})); - -vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-tab-component', () => ({ - PluginSettingsTabComponent: vi.fn() -})); - -vi.mock('obsidian-dev-utils/obsidian/data-handler', () => ({ - PluginDataHandler: vi.fn() -})); - -vi.mock('obsidian-dev-utils/function', () => ({ - noopAsync: vi.fn() -})); - -vi.mock('obsidian-dev-utils/obsidian/plugin/plugin', () => ({ - PluginBase: vi.fn() -})); - -vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-event-source', () => ({ - PluginEventSourceImpl: vi.fn() -})); - -vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-settings-tab', () => ({ - PluginSettingsTabBase: vi.fn() -})); - -vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-component', () => ({ - PluginSettingsComponentBase: vi.fn() -})); - -vi.mock('obsidian', () => ({ - Setting: vi.fn(), - TextComponent: vi.fn() -})); - -vi.mock('./command-handlers/invoke-command-handler.ts', () => ({ - InvokeCommandHandler: vi.fn() -})); - -vi.mock('./plugin-settings-component.ts', () => ({ - PluginSettingsComponent: vi.fn() -})); - -vi.mock('./plugin-settings-tab.ts', () => ({ - PluginSettingsTab: vi.fn() -})); - -// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports. import Plugin from './main.ts'; -// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports. import { Plugin as PluginClass } from './plugin.ts'; describe('main', () => { diff --git a/src/plugin-settings-component.test.ts b/src/plugin-settings-component.test.ts index 4fb9129..6d6e998 100644 --- a/src/plugin-settings-component.test.ts +++ b/src/plugin-settings-component.test.ts @@ -1,6 +1,13 @@ +import type { AsyncEventRef } from 'obsidian-dev-utils/async-events'; import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler'; import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source'; +import type { GenericObject } from 'obsidian-dev-utils/type-guards'; +import { + noop, + noopAsync +} from 'obsidian-dev-utils/function'; +import { PluginSettingsComponentBase } from 'obsidian-dev-utils/obsidian/components/plugin-settings-component'; import { strictProxy } from 'obsidian-dev-utils/strict-proxy'; import { describe, @@ -9,152 +16,83 @@ import { vi } from 'vitest'; -interface MockConstructorParams { - readonly pluginSettingsClass: new () => unknown; +import { PluginSettingsComponent } from './plugin-settings-component.ts'; +import { PluginSettings } from './plugin-settings.ts'; + +class MockDataHandler implements DataHandler { + public loadData = vi.fn(() => Promise.resolve(this.data)); + + private _data: unknown; + + public saveData = vi.fn((data: unknown) => { + this._data = data; + return noopAsync(); + }); + + public get data(): unknown { + return this._data; + } + + public constructor(data: GenericObject) { + this._data = data; + } } -const PluginSettingsComponentBaseMock = vi.hoisted(() => - class { - public readonly defaultSettings: unknown; - protected legacyConverterCalled = false; +async function createLoadedComponent(data: GenericObject): Promise { + const component = new PluginSettingsComponent({ + dataHandler: new MockDataHandler(data), + pluginEventSource: createMockPluginEventSource() + }); + await component.loadWithPromises(); + return component; +} - public constructor(params: MockConstructorParams) { - this.defaultSettings = new params.pluginSettingsClass(); +function createMockPluginEventSource(): PluginEventSource { + const source: PluginEventSource = strictProxy({ + offref: noop, + on(name: string, callback: () => void, thisArg?: unknown): AsyncEventRef { + return { asyncEventSource: source, callback, name, thisArg }; } - - protected registerLegacySettingsConverter( - _legacyClass: new () => unknown, - _converter: (settings: Record) => void - ): void { - this.legacyConverterCalled = true; - } - - protected registerLegacySettingsConverters(): void { - // Base no-op - } - } -); - -vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-component', () => ({ - PluginSettingsComponentBase: PluginSettingsComponentBaseMock -})); - -// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports. -import { PluginSettingsComponent } from './plugin-settings-component.ts'; + }); + return source; +} describe('PluginSettingsComponent', () => { - it('should create an instance', () => { + it('should be a PluginSettingsComponentBase with PluginSettings defaults', () => { const component = new PluginSettingsComponent({ dataHandler: strictProxy({}), - pluginEventSource: strictProxy({}) + pluginEventSource: createMockPluginEventSource() }); - expect(component).toBeInstanceOf(PluginSettingsComponent); + expect(component).toBeInstanceOf(PluginSettingsComponentBase); + expect(component.defaultSettings).toEqual(new PluginSettings()); }); - it('should pass pluginSettingsClass to base constructor', () => { - const component = new PluginSettingsComponent({ - dataHandler: strictProxy({}), - pluginEventSource: strictProxy({}) - }); + it('should convert legacy shouldInsertDoubleLinesBetweenAttachmentLinks=true to a double newline delimiter', async () => { + const component = await createLoadedComponent({ shouldInsertDoubleLinesBetweenAttachmentLinks: true }); - expect(component.defaultSettings).toBeDefined(); + expect(component.settings.attachmentLinksDelimiter).toBe('\n\n'); }); - it('should convert legacy shouldInsertDoubleLinesBetweenAttachmentLinks=true to double newline delimiter', () => { - const component = new PluginSettingsComponent({ - dataHandler: strictProxy({}), - pluginEventSource: strictProxy({}) - }); + it('should convert legacy shouldInsertDoubleLinesBetweenAttachmentLinks=false to a single newline delimiter', async () => { + const component = await createLoadedComponent({ shouldInsertDoubleLinesBetweenAttachmentLinks: false }); - const legacySettings: Record = { - shouldInsertDoubleLinesBetweenAttachmentLinks: true - }; - - const converter = getLegacyConverter(component); - converter(legacySettings); - - expect(legacySettings['attachmentLinksDelimiter']).toBe('\n\n'); + expect(component.settings.attachmentLinksDelimiter).toBe('\n'); }); - it('should convert legacy shouldInsertDoubleLinesBetweenAttachmentLinks=false to single newline delimiter', () => { - const component = new PluginSettingsComponent({ - dataHandler: strictProxy({}), - pluginEventSource: strictProxy({}) - }); + it('should leave the delimiter at its default when the legacy field is absent', async () => { + const component = await createLoadedComponent({}); - const legacySettings: Record = { - shouldInsertDoubleLinesBetweenAttachmentLinks: false - }; - - const converter = getLegacyConverter(component); - converter(legacySettings); - - expect(legacySettings['attachmentLinksDelimiter']).toBe('\n'); + expect(component.settings.attachmentLinksDelimiter).toBe(new PluginSettings().attachmentLinksDelimiter); }); - it('should not modify delimiter when legacy field is undefined', () => { - const component = new PluginSettingsComponent({ - dataHandler: strictProxy({}), - pluginEventSource: strictProxy({}) + it('should drop the legacy field after conversion', async () => { + const component = await createLoadedComponent({ shouldInsertDoubleLinesBetweenAttachmentLinks: true }); + + expect(component.settings).toEqual({ + attachmentLinksDelimiter: '\n\n', + attachmentLinksPrefix: '', + attachmentLinksSuffix: '' }); - - const legacySettings: Record = {}; - - const converter = getLegacyConverter(component); - converter(legacySettings); - - expect(legacySettings['attachmentLinksDelimiter']).toBeUndefined(); - }); - - it('should have shouldInsertDoubleLinesBetweenAttachmentLinks default to true in LegacySettings', () => { - const component = new PluginSettingsComponent({ - dataHandler: strictProxy({}), - pluginEventSource: strictProxy({}) - }); - - const { legacyClass } = getLegacyRegistration(component); - const legacyInstance = new legacyClass(); - - expect(legacyInstance['shouldInsertDoubleLinesBetweenAttachmentLinks']).toBe(true); }); }); - -interface CapturedLegacyRegistration { - converter(settings: Record): void; - readonly legacyClass: new () => Record; -} - -interface MockWithConverters { - capturedConverter: ((settings: Record) => void) | undefined; - registerLegacySettingsConverter(legacyClass: new () => unknown, converter: (settings: Record) => void): void; - registerLegacySettingsConverters(): void; -} - -function getLegacyConverter(component: PluginSettingsComponent): (settings: Record) => void { - return getLegacyRegistration(component).converter; -} - -function getLegacyRegistration(component: PluginSettingsComponent): CapturedLegacyRegistration { - // eslint-disable-next-line no-restricted-syntax -- test helper needs type assertion to access mock methods. - const mock = component as unknown as MockWithConverters; - let captured: CapturedLegacyRegistration | undefined; - - mock.registerLegacySettingsConverter = ( - legacyClass: new () => unknown, - converter: (settings: Record) => void - ): void => { - captured = { - converter, - legacyClass: legacyClass as new () => Record - }; - }; - - mock.registerLegacySettingsConverters(); - - if (!captured) { - throw new Error('No legacy converter was registered'); - } - - return captured; -} diff --git a/src/plugin-settings-tab.test.ts b/src/plugin-settings-tab.test.ts index 675e578..d940de3 100644 --- a/src/plugin-settings-tab.test.ts +++ b/src/plugin-settings-tab.test.ts @@ -1,9 +1,18 @@ -/* eslint-disable @typescript-eslint/no-empty-function -- Test mocks require empty constructors and flexible patterns. */ -import type { PluginSettingsTabBaseConstructorParams } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab'; +import type { + App as AppOriginal, + Plugin, + TextComponent +} from 'obsidian'; +import type { PluginSettingsComponentBase } from 'obsidian-dev-utils/obsidian/components/plugin-settings-component'; +import type { MockInstance } from 'vitest'; import { castTo } from 'obsidian-dev-utils/object-utils'; +import { strictProxy } from 'obsidian-dev-utils/strict-proxy'; import { ensureNonNullable } from 'obsidian-dev-utils/type-guards'; +import { App } from 'obsidian-test-mocks/obsidian'; import { + afterEach, + beforeEach, describe, expect, it, @@ -14,281 +23,182 @@ import type { PluginSettings } from './plugin-settings.ts'; import { PluginSettingsTab } from './plugin-settings-tab.ts'; -interface BindOptions { - readonly componentToPluginSettingsValueConverter?: ValueConverter; - readonly pluginSettingsToComponentValueConverter?: ValueConverter; +interface BindCapture { + component: TextComponent; + options: WhitespaceBindOptions; } -interface CapturedBind { - component: MockTextComponentInTab; - key: string; - options?: BindOptions; +interface WhitespaceBindOptions { + componentToPluginSettingsValueConverter?(value: string): string; + pluginSettingsToComponentValueConverter?(value: string): string; } -type ValueConverter = (value: string) => string; +// The real `bind` duck-types each component via property access (e.g. `setPlaceholderValue`), which the +// Test-mocks strict proxy rejects. It is exercised by dev-utils' own tests, so neutralizing its return value +// Is an allowed double — the real `PluginSettingsTabBase`, `Setting` and `TextComponent` are otherwise used +// Unmocked, and the tab's binding intent is asserted via the recorded `bind` calls. +let bindSpy: MockInstance; +let app: AppOriginal; -const capturedBinds: CapturedBind[] = []; -let mockContainerEl: HTMLElement; - -vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-settings-tab', () => ({ - PluginSettingsTabBase: class { - public containerEl: HTMLElement = activeDocument.createElement('div'); - - public constructor(_params: unknown) { - mockContainerEl = this.containerEl; - } - - public bind(component: MockTextComponentInTab, key: string, options?: BindOptions): unknown { - const entry: CapturedBind = { component, key }; - if (options !== undefined) { - entry.options = options; - } - capturedBinds.push(entry); - return component; - } - - public display(): void {} - } -})); - -vi.mock('obsidian-dev-utils/string', () => ({ - replace: vi.fn((str: string, replacements: Record) => { - let result = str; - for (const [from, to] of Object.entries(replacements)) { - result = result.replaceAll(from, to); - } - return result; - }) -})); - -class MockTextComponentInTab { - public inputEl: HTMLInputElement = activeDocument.createElement('input'); +function createSettingsTab(): PluginSettingsTab { + const plugin = strictProxy({ + app, + manifest: { id: 'insert-multiple-attachments' } + }); + const pluginSettingsComponent = strictProxy>({ + on: castTo['on']>(vi.fn(() => ({ + asyncEventSource: { offref: vi.fn() } + }))) + }); + return new PluginSettingsTab({ plugin, pluginSettingsComponent }); } -vi.mock('obsidian', () => ({ - Setting: class MockSetting { - public constructor(el: HTMLElement) { - el.appendChild(activeDocument.createElement('div')); - } - - public addText(cb: (text: MockTextComponentInTab) => void): this { - const mockText = new MockTextComponentInTab(); - cb(mockText); - return this; - } - - public setDesc(_desc: string): this { - return this; - } - - public setName(_name: string): this { - return this; - } - }, - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- imported module reference must exist for InstanceType resolution. - TextComponent: class MockTextComponentGlobal {} -})); +function findBind(key: keyof PluginSettings): BindCapture { + const call = ensureNonNullable(bindSpy.mock.calls.find((bindCall) => bindCall[1] === key)); + return { + component: castTo(call[0]), + options: castTo(call[2]) + }; +} describe('PluginSettingsTab', () => { - function createSettingsTab(): PluginSettingsTab { - capturedBinds.length = 0; - return new PluginSettingsTab(castTo>({})); - } - - it('should create an instance', () => { - const tab = createSettingsTab(); - - expect(tab).toBeInstanceOf(PluginSettingsTab); + beforeEach(() => { + app = App.createConfigured__().asOriginalType__(); + bindSpy = vi.spyOn(PluginSettingsTab.prototype, 'bind').mockImplementation((valueComponent) => valueComponent); }); - it('should render three settings on display()', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create an instance', () => { + expect(createSettingsTab()).toBeInstanceOf(PluginSettingsTab); + }); + + it('should render three settings on display', () => { const tab = createSettingsTab(); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); const EXPECTED_SETTING_COUNT = 3; - expect(mockContainerEl.children.length).toBe(EXPECTED_SETTING_COUNT); + expect(tab.containerEl.children.length).toBe(EXPECTED_SETTING_COUNT); }); - it('should bind attachmentLinksPrefix', () => { + it('should bind the three whitespace-aware settings', () => { const tab = createSettingsTab(); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); - expect(capturedBinds.map((b) => b.key)).toContain('attachmentLinksPrefix'); + expect(bindSpy.mock.calls.map((bindCall) => bindCall[1])).toEqual([ + 'attachmentLinksPrefix', + 'attachmentLinksDelimiter', + 'attachmentLinksSuffix' + ]); }); - it('should bind attachmentLinksDelimiter', () => { + it('should convert newline to a visible enter character when reading from plugin settings', () => { const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); - expect(capturedBinds.map((b) => b.key)).toContain('attachmentLinksDelimiter'); + const converter = ensureNonNullable(findBind('attachmentLinksPrefix').options.pluginSettingsToComponentValueConverter); + + expect(converter('\n')).toBe('↵'); }); - it('should bind attachmentLinksSuffix', () => { + it('should convert space to a visible space character when reading from plugin settings', () => { const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); - expect(capturedBinds.map((b) => b.key)).toContain('attachmentLinksSuffix'); + const converter = ensureNonNullable(findBind('attachmentLinksPrefix').options.pluginSettingsToComponentValueConverter); + + expect(converter(' ')).toBe('␣'); }); - it('should convert newline to visible enter character when reading value from plugin settings', () => { + it('should restore a visible enter character back to a newline when saving to plugin settings', () => { const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); - const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix'); - const converter = prefixBind?.options?.pluginSettingsToComponentValueConverter; - expect(converter).toBeDefined(); + const converter = ensureNonNullable(findBind('attachmentLinksPrefix').options.componentToPluginSettingsValueConverter); - expect(converter?.('\n')).toBe('↵'); + expect(converter('↵')).toBe('\n'); }); - it('should convert space to visible space character when reading value from plugin settings', () => { + it('should restore a visible space character back to a space when saving to plugin settings', () => { const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); - const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix'); - const converter = prefixBind?.options?.pluginSettingsToComponentValueConverter; - expect(converter).toBeDefined(); + const converter = ensureNonNullable(findBind('attachmentLinksPrefix').options.componentToPluginSettingsValueConverter); - expect(converter?.(' ')).toBe('␣'); + expect(converter('␣')).toBe(' '); }); - it('should restore visible enter character back to newline when saving to plugin settings', () => { + it('should replace a typed space with a visible space character in the input field', () => { const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); - const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix'); - const converter = prefixBind?.options?.componentToPluginSettingsValueConverter; - expect(converter).toBeDefined(); + const inputEl = findBind('attachmentLinksPrefix').component.inputEl; + inputEl.value = ' '; + inputEl.dispatchEvent(new Event('input')); - expect(converter?.('↵')).toBe('\n'); + expect(inputEl.value).toBe('␣'); }); - it('should restore visible space character back to space when saving to plugin settings', () => { + it('should insert a visible enter character when the user presses Enter', () => { const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); - const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix'); - const converter = prefixBind?.options?.componentToPluginSettingsValueConverter; - expect(converter).toBeDefined(); - - expect(converter?.('␣')).toBe(' '); - }); - - it('should replace visible space character when user types in input field', () => { - const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. - tab.displayLegacy(); - - const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix'); - const inputEl = prefixBind?.component.inputEl; - expect(inputEl).toBeDefined(); - - const safeInputEl = ensureNonNullable(inputEl); - safeInputEl.value = ' '; - safeInputEl.dispatchEvent(new Event('input')); - - expect(safeInputEl.value).toBe('␣'); - }); - - it('should insert visible enter character when user presses Enter key', () => { - const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. - tab.displayLegacy(); - - const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix'); - const inputEl = prefixBind?.component.inputEl; - expect(inputEl).toBeDefined(); - - const safeInputEl = ensureNonNullable(inputEl); - safeInputEl.value = 'prefix'; - Object.defineProperty(safeInputEl, 'selectionStart', { configurable: true, value: 6 }); - Object.defineProperty(safeInputEl, 'selectionEnd', { configurable: true, value: 6 }); + const inputEl = findBind('attachmentLinksPrefix').component.inputEl; + inputEl.value = 'prefix'; + Object.defineProperty(inputEl, 'selectionStart', { configurable: true, value: 6 }); + Object.defineProperty(inputEl, 'selectionEnd', { configurable: true, value: 6 }); const keypressEvent = new KeyboardEvent('keypress', { key: 'Enter' }); const preventDefaultSpy = vi.spyOn(keypressEvent, 'preventDefault'); - safeInputEl.dispatchEvent(keypressEvent); + inputEl.dispatchEvent(keypressEvent); expect(preventDefaultSpy).toHaveBeenCalled(); - expect(safeInputEl.value).toBe('prefix↵'); + expect(inputEl.value).toBe('prefix↵'); }); it('should not intercept non-Enter key presses', () => { const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); - const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix'); - const inputEl = prefixBind?.component.inputEl; - expect(inputEl).toBeDefined(); + const inputEl = findBind('attachmentLinksPrefix').component.inputEl; + inputEl.value = 'prefix'; - const safeInputEl = ensureNonNullable(inputEl); - safeInputEl.value = 'prefix'; const keypressEvent = new KeyboardEvent('keypress', { key: 'a' }); const preventDefaultSpy = vi.spyOn(keypressEvent, 'preventDefault'); - safeInputEl.dispatchEvent(keypressEvent); + inputEl.dispatchEvent(keypressEvent); expect(preventDefaultSpy).not.toHaveBeenCalled(); - expect(safeInputEl.value).toBe('prefix'); + expect(inputEl.value).toBe('prefix'); }); - it('should use 0 as fallback when selectionStart is null on input event', () => { + it('should use 0 as the fallback when selectionStart is null on an input event', () => { const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); - const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix'); - const inputEl = prefixBind?.component.inputEl; - expect(inputEl).toBeDefined(); + const inputEl = findBind('attachmentLinksPrefix').component.inputEl; + Object.defineProperty(inputEl, 'selectionStart', { configurable: true, value: null }); + Object.defineProperty(inputEl, 'selectionEnd', { configurable: true, value: null }); + inputEl.value = ' '; + inputEl.dispatchEvent(new Event('input')); - const safeInputEl = ensureNonNullable(inputEl); - Object.defineProperty(safeInputEl, 'selectionStart', { configurable: true, value: null }); - Object.defineProperty(safeInputEl, 'selectionEnd', { configurable: true, value: null }); - - safeInputEl.value = ' '; - safeInputEl.dispatchEvent(new Event('input')); - - expect(safeInputEl.value).toBe('␣'); + expect(inputEl.value).toBe('␣'); }); - it('should use 0 as fallback when selectionStart is null on Enter keypress', () => { + it('should use 0 as the fallback when selectionStart is null on an Enter keypress', () => { const tab = createSettingsTab(); - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- display() is the entry point for PluginSettingsTabBase; calling it in tests is intentional. tab.displayLegacy(); - const prefixBind = capturedBinds.find((b) => b.key === 'attachmentLinksPrefix'); - const inputEl = prefixBind?.component.inputEl; - expect(inputEl).toBeDefined(); + const inputEl = findBind('attachmentLinksPrefix').component.inputEl; + inputEl.value = 'text'; + Object.defineProperty(inputEl, 'selectionStart', { configurable: true, value: null }); + Object.defineProperty(inputEl, 'selectionEnd', { configurable: true, value: null }); - const safeInputEl = ensureNonNullable(inputEl); - safeInputEl.value = 'text'; - Object.defineProperty(safeInputEl, 'selectionStart', { configurable: true, value: null }); - Object.defineProperty(safeInputEl, 'selectionEnd', { configurable: true, value: null }); + inputEl.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter' })); - const keypressEvent = new KeyboardEvent('keypress', { key: 'Enter' }); - safeInputEl.dispatchEvent(keypressEvent); - - expect(safeInputEl.value).toBe('↵text'); + expect(inputEl.value).toBe('↵text'); }); }); -/* eslint-enable @typescript-eslint/no-empty-function -- End of test file. */ diff --git a/src/plugin.test.ts b/src/plugin.test.ts index de8e3e4..9ff8be1 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -1,188 +1,225 @@ import type { - App, - Component, + App as AppOriginal, PluginManifest } from 'obsidian'; -import { castTo } from 'obsidian-dev-utils/object-utils'; +import { AppActiveFileProvider } from 'obsidian-dev-utils/obsidian/active-file-provider'; +import { CommandHandlerComponent } from 'obsidian-dev-utils/obsidian/command-handlers/command-handler-component'; +import { PluginCommandRegistrar } from 'obsidian-dev-utils/obsidian/command-registrar'; +import { MenuEventRegistrarComponent } from 'obsidian-dev-utils/obsidian/components/menu-event-registrar-component'; +import { PluginSettingsTabComponent } from 'obsidian-dev-utils/obsidian/components/plugin-settings-tab-component'; +import { PluginDataHandler } from 'obsidian-dev-utils/obsidian/data-handler'; +import { PluginEventSourceImpl } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source'; +import { App } from 'obsidian-test-mocks/obsidian'; import { - afterEach, + beforeEach, describe, expect, it, vi } from 'vitest'; -const addedChildren: Component[] = []; -let layoutReadyCallback: (() => Promise) | undefined; +import { InvokeCommandHandler } from './command-handlers/invoke-command-handler.ts'; +import { PluginSettingsComponent } from './plugin-settings-component.ts'; +import { PluginSettingsTab } from './plugin-settings-tab.ts'; +import { Plugin } from './plugin.ts'; -const PluginBaseMock = vi.hoisted(() => - class { - public app: unknown; - public manifest: unknown; - - public constructor(app: unknown, manifest: unknown) { - this.app = app; - this.manifest = manifest; - } - - public addChild(child: T): T { - addedChildren.push(child); - return child; - } - } -); - -const CallbackLayoutReadyComponentMock = vi.hoisted(() => { - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class stores side effects in constructor. - class MockCallbackLayoutReadyComponent { - public constructor(_app: unknown, callback: () => Promise) { - layoutReadyCallback = callback; - } - } - return MockCallbackLayoutReadyComponent; -}); - -vi.mock('obsidian-dev-utils/obsidian/plugin/plugin', () => ({ - PluginBase: PluginBaseMock +// The real `PluginBase.onload()` loads dev-utils' own notice/context/debug components, which read a +// Shared-state bag off the app via `getObsidianDevUtilsState`. The strict App mock has no such bag, so +// Stub this one utility (return a fresh value wrapper per call) — mirroring dev-utils' own PluginBase test. +vi.mock('obsidian-dev-utils/obsidian/app', async (importOriginal) => ({ + ...await importOriginal(), + getObsidianDevUtilsState: vi.fn((_app: unknown, _key: string, defaultValue: unknown) => ({ value: defaultValue })) })); -vi.mock('obsidian-dev-utils/obsidian/components/layout-ready-component', () => ({ - CallbackLayoutReadyComponent: CallbackLayoutReadyComponentMock +// A dev-utils component added via `addChild` must be loadable, so its stub returns a real `Component`. The +// Flowing instance is the stub's return value (`mock.results[0].value`), not the discarded `this`. +interface ObsidianComponentModule { + Component: new () => object; +} + +const hoisted = vi.hoisted(() => ({ + settingsSentinel: {} })); -vi.mock('obsidian-dev-utils/function', () => ({ - noopAsync: vi.fn().mockResolvedValue(undefined) +async function loadableComponentStub(): Promise> { + const { Component } = await vi.importActual('obsidian'); + // Vitest requires a non-arrow function for a mock invoked with `new`; it must return a fresh real + // `Component`. Constructing a stub class directly would route `this` through vitest's mock proxy and + // Break the test-mocks `Component` constructor's own strict proxy. + // eslint-disable-next-line prefer-arrow-callback -- See above; an arrow cannot be used here. + return vi.fn(function componentStub() { + return new Component(); + }); +} + +vi.mock('obsidian-dev-utils/obsidian/components/menu-event-registrar-component', async () => ({ + MenuEventRegistrarComponent: await loadableComponentStub() +})); + +vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-tab-component', async () => ({ + PluginSettingsTabComponent: await loadableComponentStub() +})); + +vi.mock('obsidian-dev-utils/obsidian/command-handlers/command-handler-component', async () => ({ + CommandHandlerComponent: await loadableComponentStub() })); vi.mock('obsidian-dev-utils/obsidian/active-file-provider', () => ({ - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call. - AppActiveFileProvider: class MockAppActiveFileProvider {} -})); - -vi.mock('obsidian-dev-utils/obsidian/command-handlers/command-handler-component', () => ({ - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call. - CommandHandlerComponent: class MockCommandHandlerComponent {} + AppActiveFileProvider: vi.fn() })); vi.mock('obsidian-dev-utils/obsidian/command-registrar', () => ({ - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call. - PluginCommandRegistrar: class MockPluginCommandRegistrar {} -})); - -vi.mock('obsidian-dev-utils/obsidian/components/menu-event-registrar-component', () => ({ - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call. - MenuEventRegistrarComponent: class MockMenuEventRegistrarComponent {} -})); - -vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-tab-component', () => ({ - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call. - PluginSettingsTabComponent: class MockPluginSettingsTabComponent {} + PluginCommandRegistrar: vi.fn() })); vi.mock('obsidian-dev-utils/obsidian/data-handler', () => ({ - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call. - PluginDataHandler: class MockPluginDataHandler {} + PluginDataHandler: vi.fn() })); vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-event-source', () => ({ - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call. - PluginEventSourceImpl: class MockPluginEventSourceImpl {} + PluginEventSourceImpl: vi.fn() })); -vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-settings-tab', () => ({ - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call. - PluginSettingsTabBase: class MockPluginSettingsTabBase {} -})); - -vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-component', () => ({ - PluginSettingsComponentBase: class MockPluginSettingsComponentBase { - public readonly settings = {}; - } -})); - -vi.mock('obsidian', () => ({ - Setting: vi.fn(), - TextComponent: vi.fn() -})); - -interface InvokeCommandHandlerConstructorParamsMock { - getPluginSettings(): unknown; -} - -let capturedGetPluginSettings: (() => unknown) | undefined; - -vi.mock('./command-handlers/invoke-command-handler.ts', () => { - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class stores side effects in constructor. - class MockInvokeCommandHandler { - public constructor(params: InvokeCommandHandlerConstructorParamsMock) { - capturedGetPluginSettings = params.getPluginSettings.bind(params); - } - } - return { InvokeCommandHandler: MockInvokeCommandHandler }; -}); - -vi.mock('./plugin-settings-component.ts', () => ({ - PluginSettingsComponent: class MockPluginSettingsComponent { - public readonly settings = {}; - } +vi.mock('./command-handlers/invoke-command-handler.ts', () => ({ + InvokeCommandHandler: vi.fn() })); vi.mock('./plugin-settings-tab.ts', () => ({ - // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- mock class must exist for constructor call. - PluginSettingsTab: class MockPluginSettingsTab {} + PluginSettingsTab: vi.fn() })); -// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports. -import { noopAsync } from 'obsidian-dev-utils/function'; +// The plugin's own settings component is added via `addChild` (so it must be loadable) and its `settings` +// Getter is read by the `getPluginSettings` closure, so the stub exposes a sentinel settings object. +vi.mock('./plugin-settings-component.ts', () => ({ + // eslint-disable-next-line prefer-arrow-callback -- vitest requires a non-arrow function for `new`. + PluginSettingsComponent: vi.fn(function pluginSettingsComponentStub() { + return { + load: vi.fn(), + settings: hoisted.settingsSentinel + }; + }) +})); -// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports. -import { Plugin } from './plugin.ts'; +const MockAppActiveFileProvider = vi.mocked(AppActiveFileProvider); +const MockCommandHandlerComponent = vi.mocked(CommandHandlerComponent); +const MockPluginCommandRegistrar = vi.mocked(PluginCommandRegistrar); +const MockMenuEventRegistrarComponent = vi.mocked(MenuEventRegistrarComponent); +const MockPluginSettingsTabComponent = vi.mocked(PluginSettingsTabComponent); +const MockPluginDataHandler = vi.mocked(PluginDataHandler); +const MockPluginEventSourceImpl = vi.mocked(PluginEventSourceImpl); +const MockInvokeCommandHandler = vi.mocked(InvokeCommandHandler); +const MockPluginSettingsComponent = vi.mocked(PluginSettingsComponent); +const MockPluginSettingsTab = vi.mocked(PluginSettingsTab); + +const manifest: PluginManifest = { + author: 'test', + description: 'test', + id: 'insert-multiple-attachments', + minAppVersion: '1.0.0', + name: 'Insert Multiple Attachments', + version: '1.0.0' +}; + +let app: AppOriginal; + +function instanceOf(mock: ReturnType): unknown { + return mock.mock.results[0]?.value; +} describe('Plugin', () => { - afterEach(() => { - addedChildren.length = 0; - layoutReadyCallback = undefined; - capturedGetPluginSettings = undefined; - vi.restoreAllMocks(); + beforeEach(() => { vi.clearAllMocks(); + const appMock = App.createConfigured__(); + appMock.workspace.onLayoutReady = vi.fn((cb: () => void) => { + cb(); + }); + app = appMock.asOriginalType__(); }); - it('should create plugin instance', () => { - const plugin = new Plugin(castTo({}), castTo({ name: 'test-plugin' })); - - expect(plugin).toBeInstanceOf(Plugin); + it('should create a plugin instance', () => { + expect(new Plugin(app, manifest)).toBeInstanceOf(Plugin); }); - it('should add five child components in the constructor', () => { - addedChildren.length = 0; + describe('onload', () => { + it('should create PluginSettingsComponent with the data handler and plugin event source', async () => { + const plugin = new Plugin(app, manifest); + await plugin.onload(); - new Plugin(castTo({}), castTo({ name: 'test-plugin' })); + expect(MockPluginSettingsComponent).toHaveBeenCalledWith({ + dataHandler: MockPluginDataHandler.mock.instances[0], + pluginEventSource: MockPluginEventSourceImpl.mock.instances[0] + }); + }); - const EXPECTED_CHILD_COUNT = 5; - expect(addedChildren).toHaveLength(EXPECTED_CHILD_COUNT); - }); + it('should create PluginSettingsTab with the plugin and settings component', async () => { + const plugin = new Plugin(app, manifest); + await plugin.onload(); - it('should register a layout ready callback', () => { - new Plugin(castTo({}), castTo({ name: 'test-plugin' })); + expect(MockPluginSettingsTab).toHaveBeenCalledWith({ + plugin, + pluginSettingsComponent: instanceOf(MockPluginSettingsComponent) + }); + }); - expect(layoutReadyCallback).toBeDefined(); - }); + it('should create PluginSettingsTabComponent with the plugin and settings tab', async () => { + const plugin = new Plugin(app, manifest); + await plugin.onload(); - it('should call noopAsync when onLayoutReady fires', async () => { - new Plugin(castTo({}), castTo({ name: 'test-plugin' })); + expect(MockPluginSettingsTabComponent).toHaveBeenCalledWith({ + plugin, + pluginSettingsTab: MockPluginSettingsTab.mock.instances[0] + }); + }); - await layoutReadyCallback?.(); + it('should create MenuEventRegistrarComponent with the app', async () => { + const plugin = new Plugin(app, manifest); + await plugin.onload(); - expect(noopAsync).toHaveBeenCalled(); - }); + expect(MockMenuEventRegistrarComponent).toHaveBeenCalledWith(app); + }); - it('should provide working getPluginSettings that returns settings', () => { - new Plugin(castTo({}), castTo({ name: 'test-plugin' })); + it('should create InvokeCommandHandler with the app, settings getter, and plugin name', async () => { + const plugin = new Plugin(app, manifest); + await plugin.onload(); - expect(capturedGetPluginSettings).toBeDefined(); - const settings = capturedGetPluginSettings?.(); + const params = MockInvokeCommandHandler.mock.calls[0]?.[0]; + expect(params?.app).toBe(app); + expect(params?.pluginName).toBe(manifest.name); + expect(params?.getPluginSettings).toBeTypeOf('function'); + }); - expect(settings).toBeDefined(); + it('should create CommandHandlerComponent wiring the invoke command and menu registrar', async () => { + const plugin = new Plugin(app, manifest); + await plugin.onload(); + + expect(MockCommandHandlerComponent).toHaveBeenCalledWith({ + activeFileProvider: MockAppActiveFileProvider.mock.instances[0], + commandHandlers: [MockInvokeCommandHandler.mock.instances[0]], + commandRegistrar: MockPluginCommandRegistrar.mock.instances[0], + menuEventRegistrar: instanceOf(MockMenuEventRegistrarComponent), + pluginName: manifest.name + }); + }); + + it('should add the four plugin components as children', async () => { + const plugin = new Plugin(app, manifest); + const addChildSpy = vi.spyOn(plugin, 'addChild'); + await plugin.onload(); + + expect(addChildSpy).toHaveBeenCalledWith(instanceOf(MockPluginSettingsComponent)); + expect(addChildSpy).toHaveBeenCalledWith(instanceOf(MockPluginSettingsTabComponent)); + expect(addChildSpy).toHaveBeenCalledWith(instanceOf(MockMenuEventRegistrarComponent)); + expect(addChildSpy).toHaveBeenCalledWith(instanceOf(MockCommandHandlerComponent)); + }); + + it('should provide a getPluginSettings function that returns the component settings', async () => { + const plugin = new Plugin(app, manifest); + await plugin.onload(); + + const params = MockInvokeCommandHandler.mock.calls[0]?.[0]; + const settings: unknown = params?.getPluginSettings(); + expect(settings).toBe(hoisted.settingsSentinel); + }); }); });