test: cleanup

This commit is contained in:
Michael Naumov 2026-06-21 01:19:18 -06:00
parent f21aea996b
commit 14b711cda5
2 changed files with 167 additions and 129 deletions

View file

@ -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<unknown> {
await noopAsync();
return {};
}
return { SettingEx: MockSettingEx };
public async saveData(): Promise<void> {
await noopAsync();
}
}
async function createTab(): Promise<PluginSettingsTab> {
const app = App.createConfigured__();
const pluginSettingsComponent = new PluginSettingsComponent({
dataHandler: new MockDataHandler(),
pluginEventSource: new AsyncEvents<PluginEventMap>()
});
// 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<Plugin>({ 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<PluginSettings>({
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<PluginSettingsComponentBase<PluginSettings>>({
on: vi.fn().mockReturnValue({ id: 'ref' }),
settings
});
const plugin = strictProxy<Plugin>({
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'
]);
});
});

View file

@ -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<PluginManifest>({
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<Plugin> {
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<App>({});
}
function createMockManifest(): PluginManifest {
return strictProxy<PluginManifest>({
id: 'backlink-full-path',
name: 'Backlink Full Path'
});
function seedOnRawTarget(strictProxiedObject: object, key: string, value: unknown): void {
const proxyWithTarget = castTo<Partial<Record<symbol, object>>>(strictProxiedObject);
const rawTarget = proxyWithTarget[STRICT_PROXY_TARGET_SYMBOL] ?? strictProxiedObject;
castTo<Record<string, unknown>>(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<PluginInternals>(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<AppGlobal>(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<SettingTabsHolder>(plugin).settingTabs__).toHaveLength(1);
});
});
/* eslint-enable @typescript-eslint/no-extraneous-class -- End of test file. */