test: drive real dev-utils bases instead of re-creating them

The unit tests mocked obsidian-dev-utils base classes (PluginBase,
EditorCommandHandler, PluginSettingsTabBase, PluginSettingsComponentBase)
and wholesale-replaced the obsidian module, re-creating their bodies. Those
fakes had drifted from obsidian-dev-utils 74.1.0, leaving the suite red.

Convert every unit test to the real bases via the obsidian-test-mocks
bridge:
- plugin: driven through the real PluginBase.onload lifecycle
- invoke command handler: real EditorCommandHandler via buildCommand
- settings component: real legacy-converter flow via loadWithPromises
- settings tab: real base + real Setting/TextComponent, bind neutralized
- insert attachments control: real basename/extname, only convertAsyncToSync
  kept as the sanctioned identity stub

Add offref to cspell words.
This commit is contained in:
Michael Naumov 2026-06-21 03:14:16 -06:00
parent b410dbbaf8
commit 7bdb13d51c
7 changed files with 393 additions and 621 deletions

View file

@ -11,6 +11,7 @@
"words": [
"mnaoumov",
"Naumov",
"offref",
"tsbuildinfo"
],
"ignoreWords": [],

View file

@ -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<PluginSettings>): InvokeCommandHandler {
return new InvokeCommandHandler({
app: app ?? strictProxy<App>({}),
getPluginSettings: (): ReadonlyDeep<PluginSettings> => settings ?? strictProxy<PluginSettings>({}),
pluginName: 'test-plugin'
});
}
describe('InvokeCommandHandler', () => {
it('should create an instance', (): void => {
const handler = new InvokeCommandHandler({
app: strictProxy<App>({}),
getPluginSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<PluginSettings>({}),
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<App>({}),
getPluginSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<PluginSettings>({}),
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<App>({}),
getPluginSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<PluginSettings>({}),
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<App>({}),
getPluginSettings: (): ReadonlyDeep<PluginSettings> => strictProxy<PluginSettings>({}),
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<App>({});
const mockSettings = strictProxy<PluginSettings>({});
const handler = new InvokeCommandHandler({
app: mockApp,
getPluginSettings: (): ReadonlyDeep<PluginSettings> => mockSettings,
pluginName: 'test-plugin'
});
it('should build a command with an editorCheckCallback', (): void => {
const command = createHandler().buildCommand();
const mockEditor = strictProxy<Editor>({});
const mockCtx = strictProxy<MarkdownFileInfo>({});
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<App>({});
const settings = strictProxy<PluginSettings>({});
const handler = createHandler(app, settings);
const editor = strictProxy<Editor>({});
const ctx = strictProxy<MarkdownFileInfo>({});
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<Editor>({});
const ctx = strictProxy<MarkdownFileInfo>({});
const command = handler.buildCommand();
const result = command.editorCheckCallback?.(true, editor, ctx);
expect(result).toBe(true);
expect(hoisted.mockInsertAttachmentsControlConstructor).not.toHaveBeenCalled();
});
});

View file

@ -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<typeof import('obsidian-dev-utils/async')>(),
convertAsyncToSync: vi.fn((fn: (...args: unknown[]) => unknown) => fn)
}));
interface CreateControlParams {
@ -68,7 +59,6 @@ interface MockFileList {
}
let mockFileEl: MockFileInput;
let capturedChangeHandler: (() => Promise<void>) | undefined;
let mockDocumentAddEventListener: ReturnType<typeof vi.fn>;
let mockDocumentRemoveEventListener: ReturnType<typeof vi.fn>;
let mockSetTimeout: ReturnType<typeof vi.fn>;
@ -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<void>) | undefined;
await changeHandler?.();
}
};
return el;
@ -128,7 +118,6 @@ function createMockSettings(overrides?: Partial<PluginSettings>): ReadonlyDeep<P
describe('InsertAttachmentsControl', () => {
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<void>) => {
capturedChangeHandler = fn;
return fn;
});
hoisted.mockBasename.mockImplementation((name: string, ext?: string) => {
if (ext) {
return name.replace(ext, '');
}
return name;
});
hoisted.mockExtname.mockReturnValue('.png');
});
afterEach(() => {

View file

@ -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', () => {

View file

@ -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<PluginSettingsComponent> {
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<PluginEventSource>({
offref: noop,
on(name: string, callback: () => void, thisArg?: unknown): AsyncEventRef {
return { asyncEventSource: source, callback, name, thisArg };
}
protected registerLegacySettingsConverter(
_legacyClass: new () => unknown,
_converter: (settings: Record<string, unknown>) => 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<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
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<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
});
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<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
});
it('should convert legacy shouldInsertDoubleLinesBetweenAttachmentLinks=false to a single newline delimiter', async () => {
const component = await createLoadedComponent({ shouldInsertDoubleLinesBetweenAttachmentLinks: false });
const legacySettings: Record<string, unknown> = {
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<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
});
it('should leave the delimiter at its default when the legacy field is absent', async () => {
const component = await createLoadedComponent({});
const legacySettings: Record<string, unknown> = {
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<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
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<string, unknown> = {};
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<DataHandler>({}),
pluginEventSource: strictProxy<PluginEventSource>({})
});
const { legacyClass } = getLegacyRegistration(component);
const legacyInstance = new legacyClass();
expect(legacyInstance['shouldInsertDoubleLinesBetweenAttachmentLinks']).toBe(true);
});
});
interface CapturedLegacyRegistration {
converter(settings: Record<string, unknown>): void;
readonly legacyClass: new () => Record<string, unknown>;
}
interface MockWithConverters {
capturedConverter: ((settings: Record<string, unknown>) => void) | undefined;
registerLegacySettingsConverter(legacyClass: new () => unknown, converter: (settings: Record<string, unknown>) => void): void;
registerLegacySettingsConverters(): void;
}
function getLegacyConverter(component: PluginSettingsComponent): (settings: Record<string, unknown>) => 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<string, unknown>) => void
): void => {
captured = {
converter,
legacyClass: legacyClass as new () => Record<string, unknown>
};
};
mock.registerLegacySettingsConverters();
if (!captured) {
throw new Error('No legacy converter was registered');
}
return captured;
}

View file

@ -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<PluginSettingsTab['bind']>;
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<string, string>) => {
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<Plugin>({
app,
manifest: { id: 'insert-multiple-attachments' }
});
const pluginSettingsComponent = strictProxy<PluginSettingsComponentBase<PluginSettings>>({
on: castTo<PluginSettingsComponentBase<PluginSettings>['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<TextComponent>(call[0]),
options: castTo<WhitespaceBindOptions>(call[2])
};
}
describe('PluginSettingsTab', () => {
function createSettingsTab(): PluginSettingsTab {
capturedBinds.length = 0;
return new PluginSettingsTab(castTo<PluginSettingsTabBaseConstructorParams<PluginSettings>>({}));
}
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. */

View file

@ -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<void>) | 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<T extends Component>(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<void>) {
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<typeof import('obsidian-dev-utils/obsidian/app')>(),
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<ReturnType<typeof vi.fn>> {
const { Component } = await vi.importActual<ObsidianComponentModule>('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<typeof vi.fn>): 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<App>({}), castTo<PluginManifest>({ 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<App>({}), castTo<PluginManifest>({ 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<App>({}), castTo<PluginManifest>({ 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<App>({}), castTo<PluginManifest>({ 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<App>({}), castTo<PluginManifest>({ 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);
});
});
});