mirror of
https://github.com/mnaoumov/obsidian-insert-multiple-attachments.git
synced 2026-07-22 11:30:26 +00:00
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:
parent
b410dbbaf8
commit
7bdb13d51c
7 changed files with 393 additions and 621 deletions
|
|
@ -11,6 +11,7 @@
|
|||
"words": [
|
||||
"mnaoumov",
|
||||
"Naumov",
|
||||
"offref",
|
||||
"tsbuildinfo"
|
||||
],
|
||||
"ignoreWords": [],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue