mirror of
https://github.com/mnaoumov/obsidian-backlink-full-path.git
synced 2026-07-22 05:41:39 +00:00
test: cleanup
This commit is contained in:
parent
f21aea996b
commit
14b711cda5
2 changed files with 167 additions and 129 deletions
|
|
@ -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'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue