diff --git a/src/plugin-settings-tab.test.ts b/src/plugin-settings-tab.test.ts index bffd292..b503fb6 100644 --- a/src/plugin-settings-tab.test.ts +++ b/src/plugin-settings-tab.test.ts @@ -1,91 +1,95 @@ -import type { Plugin } from 'obsidian'; -import type { PluginSettingsComponentBase } from 'obsidian-dev-utils/obsidian/components/plugin-settings-component'; +import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler'; +import type { PluginEventMap } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source'; +import { AsyncEvents } from 'obsidian-dev-utils/async-events'; +import { noopAsync } from 'obsidian-dev-utils/function'; import { strictProxy } from 'obsidian-dev-utils/strict-proxy'; import { + App, + DropdownComponent as DropdownComponentClass, + TextComponent as TextComponentClass, + ToggleComponent as ToggleComponentClass +} from 'obsidian-test-mocks/obsidian'; +import { + beforeAll, describe, expect, - it, - vi + it } from 'vitest'; -import type { PluginSettings } from './plugin-settings.ts'; +import type { Plugin } from './plugin.ts'; +import { PluginSettingsComponent } from './plugin-settings-component.ts'; import { PluginSettingsTab } from './plugin-settings-tab.ts'; -vi.mock('obsidian-dev-utils/obsidian/setting-ex', () => { - class MockSettingEx { - public constructor(public readonly containerEl: HTMLElement) { - } - - public addMultipleText(cb: (component: unknown) => void): this { - cb({ setMin: vi.fn() }); - return this; - } - - public addNumber(cb: (component: unknown) => void): this { - cb({ setMin: vi.fn() }); - return this; - } - - public setDesc(): this { - return this; - } - - public setName(): this { - return this; - } +class MockDataHandler implements DataHandler { + public async loadData(): Promise { + await noopAsync(); + return {}; } - return { SettingEx: MockSettingEx }; + public async saveData(): Promise { + await noopAsync(); + } +} + +async function createTab(): Promise { + const app = App.createConfigured__(); + const pluginSettingsComponent = new PluginSettingsComponent({ + dataHandler: new MockDataHandler(), + pluginEventSource: new AsyncEvents() + }); + // The component must be loaded before its settings can be edited; obsidian-dev-utils. + // Makes setProperty/editAndSave throw when the component is not loaded. + await pluginSettingsComponent.loadWithPromises(); + const plugin = strictProxy({ app: app.asOriginalType__() }); + const tab = new PluginSettingsTab({ + plugin, + pluginSettingsComponent + }); + + tab.displayLegacy(); + return tab; +} + +function getSettingNames(tab: PluginSettingsTab): string[] { + const names: string[] = []; + for (const settingEl of Array.from(tab.containerEl.children)) { + const infoEl = settingEl.children[1]; + const nameEl = infoEl?.children[0]; + if (nameEl?.textContent) { + names.push(nameEl.textContent); + } + } + return names; +} + +beforeAll(() => { + // Obsidian-dev-utils' bind() probes setPlaceholderValue to detect text-based components. + for (const proto of [ToggleComponentClass.prototype, DropdownComponentClass.prototype, TextComponentClass.prototype]) { + if (!('setPlaceholderValue' in proto)) { + Object.defineProperty(proto, 'setPlaceholderValue', { value: undefined }); + } + } }); describe('PluginSettingsTab', () => { - it('should display all settings bound to the correct properties', () => { - const settings = strictProxy({ - pathDepth: 0, - rootPaths: [], - shouldDisplayParentPathOnSeparateLine: false, - shouldHighlightFileName: true, - shouldIncludeExtension: true, - shouldReversePathParts: false, - shouldShowEllipsisForSkippedPathParts: true - }); + it('should be constructable', async () => { + const tab = await createTab(); + expect(tab).toBeInstanceOf(PluginSettingsTab); + }); - const pluginSettingsComponent = strictProxy>({ - on: vi.fn().mockReturnValue({ id: 'ref' }), - settings - }); - - const plugin = strictProxy({ - app: { - workspace: { - on: vi.fn().mockReturnValue({ id: 'test' }) - } - } - }); - - const tab = new PluginSettingsTab({ - plugin, - pluginSettingsComponent - }); - - tab.containerEl = activeDocument.createElement('div'); - - const bindSpy = vi.spyOn(tab, 'bind').mockReturnValue({ setMin: vi.fn() }); - - tab.displayLegacy(); - - const EXPECTED_BIND_COUNT = 7; - expect(bindSpy).toHaveBeenCalledTimes(EXPECTED_BIND_COUNT); - expect(bindSpy.mock.calls.map((call) => call[1])).toEqual([ - 'shouldIncludeExtension', - 'pathDepth', - 'shouldShowEllipsisForSkippedPathParts', - 'shouldHighlightFileName', - 'shouldReversePathParts', - 'shouldDisplayParentPathOnSeparateLine', - 'rootPaths' + it('should render all settings bound to the correct properties', async () => { + const tab = await createTab(); + const names = getSettingNames(tab); + expect(names).toStrictEqual([ + 'Include extension', + 'Path depth', + 'Show ellipsis for skipped path parts', + 'Highlight file name', + 'Reverse path parts', + 'Display parent path on separate line', + 'Root paths' ]); }); }); diff --git a/src/plugin.test.ts b/src/plugin.test.ts index d157bab..a5071e0 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -1,90 +1,124 @@ +/* eslint-disable @typescript-eslint/no-extraneous-class -- Test mocks of the plugin's own sibling modules need constructor-only classes. */ import type { - App, + App as AppOriginal, PluginManifest } from 'obsidian'; +import { Component } from 'obsidian'; import { castTo } from 'obsidian-dev-utils/object-utils'; -import { strictProxy } from 'obsidian-dev-utils/strict-proxy'; +import { App } from 'obsidian-test-mocks/obsidian'; import { + beforeEach, describe, expect, it, vi } from 'vitest'; -import { BacklinkFullPathComponent } from './backlink-full-path-component.ts'; -import { PluginSettingsComponent } from './plugin-settings-component.ts'; -import { PluginSettingsTab } from './plugin-settings-tab.ts'; -import { Plugin } from './plugin.ts'; +interface AppGlobal { + app: AppOriginal; +} -vi.mock('obsidian-dev-utils/obsidian/components/plugin-settings-tab-component', () => ({ - PluginSettingsTabComponent: vi.fn() -})); +interface SettingTabsHolder { + settingTabs__: unknown[]; +} -vi.mock('obsidian-dev-utils/obsidian/data-handler', () => ({ - PluginDataHandler: vi.fn() -})); +const STRICT_PROXY_TARGET_SYMBOL = Symbol.for('strictProxyTarget'); -vi.mock('obsidian-dev-utils/obsidian/plugin/plugin', () => { - class MockPluginBase { - public app: App; - public manifest: PluginManifest; +// --- Mocks for the plugin's OWN sibling modules (allowed: not obsidian-dev-utils / obsidian-test-mocks) --- - public constructor(app: App, manifest: PluginManifest) { - this.app = app; - this.manifest = manifest; - } - - public addChild(child: unknown): unknown { - return child; - } - } - return { PluginBase: MockPluginBase }; -}); - -vi.mock('obsidian-dev-utils/obsidian/plugin/plugin-event-source', () => ({ - PluginEventSourceImpl: vi.fn() -})); - -vi.mock('./backlink-full-path-component.ts', () => ({ - BacklinkFullPathComponent: vi.fn() +const hoisted = vi.hoisted(() => ({ + backlinkFullPathComponentConstructor: vi.fn(), + pluginSettingsComponentConstructor: vi.fn(), + pluginSettingsTabConstructor: vi.fn() })); vi.mock('./plugin-settings-component.ts', () => ({ - PluginSettingsComponent: vi.fn() + // Extends the real obsidian-test-mocks Component so the real addChild lifecycle can load it. + PluginSettingsComponent: class extends Component { + public constructor(params: unknown) { + super(); + hoisted.pluginSettingsComponentConstructor(params); + } + } })); vi.mock('./plugin-settings-tab.ts', () => ({ - PluginSettingsTab: vi.fn() + PluginSettingsTab: class { + public constructor(params: unknown) { + hoisted.pluginSettingsTabConstructor(params); + } + } })); -interface PluginInternals { - onloadImpl(): void; +vi.mock('./backlink-full-path-component.ts', () => ({ + // Extends the real obsidian-test-mocks Component so the real addChild lifecycle can load it. + BacklinkFullPathComponent: class extends Component { + public constructor(params: unknown) { + super(); + hoisted.backlinkFullPathComponentConstructor(params); + } + } +})); + +// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports. +import { Plugin } from './plugin.ts'; + +const manifest = castTo({ + author: 'test', + description: 'test', + id: 'backlink-full-path', + minAppVersion: '1.0.0', + name: 'Backlink Full Path', + version: '1.0.0' +}); + +let app: AppOriginal; + +async function createLoadedPlugin(): Promise { + const plugin = new Plugin(app, manifest); + // PluginBase.onload is async, and the synchronous mock Component.load() would not await it, so the real async load path is driven directly (as the obsidian-dev-utils reference test does). + await plugin.onload(); + return plugin; } -function createMockApp(): App { - return strictProxy({}); -} - -function createMockManifest(): PluginManifest { - return strictProxy({ - id: 'backlink-full-path', - name: 'Backlink Full Path' - }); +function seedOnRawTarget(strictProxiedObject: object, key: string, value: unknown): void { + const proxyWithTarget = castTo>>(strictProxiedObject); + const rawTarget = proxyWithTarget[STRICT_PROXY_TARGET_SYMBOL] ?? strictProxiedObject; + castTo>(rawTarget)[key] = value; } describe('Plugin', () => { - it('should wire up all components in onloadImpl', () => { - const app = createMockApp(); - const plugin = new Plugin(app, createMockManifest()); - const addChildSpy = vi.spyOn(plugin, 'addChild'); + beforeEach(() => { + vi.clearAllMocks(); + const appMock = App.createConfigured__(); + appMock.workspace.onLayoutReady = vi.fn((cb: () => void) => { + cb(); + }); + app = appMock.asOriginalType__(); - castTo(plugin).onloadImpl(); + // Seed the obsidianDevUtilsState holder on the raw target behind the strict-proxy App so the real getObsidianDevUtilsState can read/write it (the proxy throws on first access to an unassigned property). + seedOnRawTarget(app, 'obsidianDevUtilsState', {}); - expect(PluginSettingsComponent).toHaveBeenCalledOnce(); - expect(PluginSettingsTab).toHaveBeenCalledOnce(); - expect(BacklinkFullPathComponent).toHaveBeenCalledOnce(); - const EXPECTED_ADD_CHILD_COUNT = 3; - expect(addChildSpy).toHaveBeenCalledTimes(EXPECTED_ADD_CHILD_COUNT); + // Expose the app as the global instance so dev-utils helpers that resolve shared state without an explicit app argument read/write the same seeded holder. + castTo(window).app = app; + }); + + it('should load the plugin without throwing', async () => { + const plugin = await createLoadedPlugin(); + expect(plugin).toBeInstanceOf(Plugin); + }); + + it('should wire up all components in onloadImpl', async () => { + await createLoadedPlugin(); + expect(hoisted.pluginSettingsComponentConstructor).toHaveBeenCalledOnce(); + expect(hoisted.pluginSettingsTabConstructor).toHaveBeenCalledOnce(); + expect(hoisted.backlinkFullPathComponentConstructor).toHaveBeenCalledOnce(); + }); + + it('should register the settings tab with the plugin', async () => { + const plugin = await createLoadedPlugin(); + expect(castTo(plugin).settingTabs__).toHaveLength(1); }); }); +/* eslint-enable @typescript-eslint/no-extraneous-class -- End of test file. */