import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian"; import type { PageFunctions } from "./SettingPane.ts"; const runtime = vi.hoisted(() => ({ components: [] as Array<{ load: ReturnType; unload: ReturnType; callbacks: Array<() => unknown>; }>, paneChangeLog: vi.fn(), pageCleanup: vi.fn(), savedEffect: vi.fn(), superHide: vi.fn(), })); function createElement(): HTMLElement { const element = { empty: vi.fn(), addClass: vi.fn(), removeClass: vi.fn(), toggleClass: vi.fn(), createEl: vi.fn(() => createElement()), createDiv: vi.fn(() => createElement()), querySelectorAll: vi.fn(() => []), }; return element as unknown as HTMLElement; } vi.mock("@/deps.ts", () => ({ App: class {}, Component: class { callbacks: Array<() => unknown> = []; load = vi.fn(); unload = vi.fn(() => { for (const callback of this.callbacks.splice(0)) callback(); }); register = vi.fn((callback: () => unknown) => this.callbacks.push(callback)); constructor() { runtime.components.push(this); } }, PluginSettingTab: class { app: unknown; plugin: unknown; refreshDomState = vi.fn(); update = vi.fn(); constructor(app: unknown, plugin: unknown) { this.app = app; this.plugin = plugin; } hide() {} }, SettingPage: class { containerEl = createElement(); title = ""; display() {} hide() { runtime.superHide(); } }, requireApiVersion: vi.fn(() => true), })); vi.mock("@/main.ts", () => ({ default: class {} })); vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({ getLanguage: vi.fn(() => "en"), compatGlobal: { localStorage: { getItem: vi.fn(() => null), setItem: vi.fn(), }, }, })); vi.mock("@/common/events.ts", () => ({ EVENT_ON_UNRESOLVED_ERROR: "on-unresolved-error", EVENT_REQUEST_COPY_SETUP_URI: "request-copy-setup-uri", EVENT_REQUEST_OPEN_SETUP_URI: "request-open-setup-uri", EVENT_REQUEST_RELOAD_SETTING_TAB: "request-reload-setting-tab", EVENT_REQUEST_SHOW_SETUP_QR: "request-show-setup-qr", eventHub: { emitEvent: vi.fn(), onEvent: vi.fn() }, })); vi.mock("@/modules/features/SetupManager.ts", () => ({ SetupManager: class {} })); vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({ checkSyncInfo: vi.fn() })); vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({ LiveSyncCouchDBReplicator: class {}, })); vi.mock("./LiveSyncSetting.ts", () => ({ LiveSyncSetting: class { static env: unknown; }, })); vi.mock("./SettingPane.ts", () => ({ enableOnly: vi.fn((condition: () => boolean) => () => ({ disabled: !condition() })), setLevelClass: vi.fn(), setStyle: vi.fn(), visibleOnly: vi.fn((condition: () => boolean) => () => ({ visibility: condition() })), })); vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: runtime.paneChangeLog })); vi.mock("./PaneQuickSetup.ts", () => ({ paneQuickSetup: vi.fn() })); vi.mock("./PaneHelp.ts", () => ({ paneHelp: vi.fn() })); vi.mock("./PaneGeneral.ts", () => ({ paneGeneral: vi.fn() })); vi.mock("./PaneRemoteConfig.ts", () => ({ paneRemoteConfig: vi.fn() })); vi.mock("./PaneSelector.ts", () => ({ paneSelector: vi.fn() })); vi.mock("./PaneSyncSettings.ts", () => ({ paneSyncSettings: vi.fn() })); vi.mock("./PaneCustomisationSync.ts", () => ({ paneCustomisationSync: vi.fn() })); vi.mock("./PaneHatch.ts", () => ({ paneHatch: vi.fn() })); vi.mock("./PaneAdvanced.ts", () => ({ paneAdvanced: vi.fn() })); vi.mock("./PanePowerUsers.ts", () => ({ panePowerUsers: vi.fn() })); vi.mock("./PanePatches.ts", () => ({ panePatches: vi.fn() })); vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() })); import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import { createSettingsPageCatalogue } from "./SettingsPageCatalogue.ts"; function isPage(item: SettingDefinitionItem): item is SettingDefinitionPage { return "type" in item && item.type === "page"; } function isGroup(item: SettingDefinitionItem): item is SettingDefinitionGroup { return "type" in item && item.type === "group"; } function isAction(item: SettingDefinitionItem): item is Extract { return "action" in item && typeof item.action === "function"; } function itemLabel(item: SettingDefinitionItem): string { if (isPage(item)) return item.name; if (isGroup(item)) return item.heading ?? ""; return item.name; } function collectPages(items: readonly SettingDefinitionItem[]): SettingDefinitionPage[] { return items.flatMap((item) => { if (isPage(item)) return [item, ...collectPages(item.items ?? [])]; if (isGroup(item)) return collectPages(item.items ?? []); return []; }); } function findPage(tab: ObsidianLiveSyncSettingTab, name: string): SettingDefinitionPage { const page = collectPages(tab.getSettingDefinitions()).find((candidate) => candidate.name.endsWith(` ${name}`)); if (!page) throw new Error(`${name} custom page is unavailable`); return page; } function createSettingsTab(): ObsidianLiveSyncSettingTab { const plugin = { app: {}, core: { settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, confirm: { askInPopup: vi.fn(), }, services: { setting: { getDeviceAndVaultName: vi.fn(() => ""), saveSettingData: vi.fn(async () => undefined), }, }, }, }; const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never); Object.assign(tab, { _editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, initialSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, }); return tab; } beforeEach(() => { runtime.components.length = 0; runtime.paneChangeLog.mockClear(); runtime.paneChangeLog.mockImplementation(function (this: ObsidianLiveSyncSettingTab) { this.lifetimeComponent.register(runtime.pageCleanup); }); runtime.pageCleanup.mockClear(); runtime.savedEffect.mockClear(); runtime.superHide.mockClear(); }); describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { it("keeps Quick Setup first while synchronisation is inactive and separates synchronisation pages from it", () => { const tab = createSettingsTab(); const definitions = tab.getSettingDefinitions().filter(isGroup); expect(definitions.slice(0, 3).map(itemLabel)).toEqual([ "đŸ§™â€â™‚ī¸ Quick Setup", "🔄 Synchronisation", "âš™ī¸ General Settings", ]); }); it("keeps the synchronisation group first and orders General Settings before Quick Setup while synchronisation is active", () => { const tab = createSettingsTab(); tab.editingSettings.liveSync = true; const definitions = tab.getSettingDefinitions().filter(isGroup); expect(definitions.slice(0, 3).map(itemLabel)).toEqual([ "🔄 Synchronisation", "âš™ī¸ General Settings", "đŸ§™â€â™‚ī¸ Quick Setup", ]); }); it("keeps the pending initialisation action visible on the root settings page", () => { const tab = createSettingsTab(); tab.editingSettings.handleFilenameCaseSensitive = !tab.initialSettings!.handleFilenameCaseSensitive; const action = tab.getSettingDefinitions().find(isAction); expect(action?.name).toBe("Apply"); expect(typeof action?.visible === "function" ? action.visible() : action?.visible).toBe(true); }); it("keeps Remote Configuration and Sync Settings as native pages inside the Synchronisation group", () => { const tab = createSettingsTab(); const definitions = tab.getSettingDefinitions(); const synchronisation = definitions.find( (item): item is SettingDefinitionGroup => isGroup(item) && item.heading === "🔄 Synchronisation" ); expect(synchronisation?.items?.filter(isPage).map(({ name }) => name)).toEqual([ "đŸ›°ī¸ Remote Configuration", "🔄 Sync Settings", ]); expect( definitions .filter(isPage) .map(({ name }) => name) .filter((name) => name.endsWith(" Remote Configuration") || name.endsWith(" Sync Settings")) ).toEqual([]); }); it("groups secondary pages by purpose instead of exposing a flat Detailed settings list", () => { const tab = createSettingsTab(); const definitions = tab.getSettingDefinitions(); const groups = definitions.filter(isGroup); expect(groups.map(({ heading }) => heading)).toEqual([ "đŸ§™â€â™‚ī¸ Quick Setup", "🔄 Synchronisation", "âš™ī¸ General Settings", "📲 Set up other devices", "đŸ› ī¸ Maintenance and recovery", "🧩 Extra features", "🔧 Advanced settings", "â„šī¸ Help and information", ]); expect( groups .find(({ heading }) => heading === "đŸ› ī¸ Maintenance and recovery") ?.items?.filter(isPage) .map(({ name }) => name) ).toEqual(["đŸŽ›ī¸ Maintenance", "🧰 Hatch"]); expect( groups .find(({ heading }) => heading === "🧩 Extra features") ?.items?.filter(isPage) .map(({ name }) => name) ).toEqual(["đŸšĻ Selector", "🔌 Customisation sync"]); expect( groups .find(({ heading }) => heading === "🔧 Advanced settings") ?.items?.filter(isPage) .map(({ name }) => name) ).toEqual(["🔧 Advanced", "đŸ’Ē Power users", "🩹 Patches"]); expect( groups .find(({ heading }) => heading === "â„šī¸ Help and information") ?.items?.filter(isPage) .map(({ name }) => name) ).toEqual(["❓ Help and troubleshooting", "đŸ’Ŧ Change Log"]); expect( groups.find(({ heading }) => heading === "📲 Set up other devices")?.items?.map(({ name }) => name) ).toEqual(["Copy the current settings to a Setup URI", "Show QR code"]); }); it("keeps Appearance, Logging, and Extra menus inside General Settings", () => { const tab = createSettingsTab(); const definitions = tab.getSettingDefinitions(); const general = definitions.find( (item): item is SettingDefinitionGroup => isGroup(item) && item.heading === "âš™ī¸ General Settings" ); const generalPages = general?.items?.filter(isPage); const appearance = generalPages?.find(({ name }) => name === "🎨 Appearance"); const logging = generalPages?.find(({ name }) => name === "📝 Logging"); const extraMenus = general?.items?.find( (item): item is SettingDefinitionPage => isPage(item) && item.name === "đŸŽšī¸ Extra menus" ); expect(generalPages?.map(({ name }) => name)).toEqual(["🎨 Appearance", "📝 Logging", "đŸŽšī¸ Extra menus"]); expect( appearance?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : [])) ).toEqual([ "displayLanguage", "showStatusOnEditor", "showOnlyIconsOnEditor", "showStatusOnStatusbar", "hideFileWarningNotice", "networkWarningStyle", ]); expect( logging?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : [])) ).toEqual(["lessInformationInLog", "showVerboseLog"]); expect( extraMenus?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : [])) ).toEqual(["useAdvancedMode", "usePowerUserMode", "useEdgeCaseMode"]); }); it("omits the old Setup child page and keeps standard General and Advanced pages native", () => { const tab = createSettingsTab(); const pages = collectPages(tab.getSettingDefinitions()); expect(pages).toHaveLength(14); expect(pages.map(({ name }) => name)).toEqual( expect.arrayContaining( createSettingsPageCatalogue() .filter(({ id }) => id !== "general" && id !== "quick-setup") .map((entry) => `${entry.icon} ${entry.name()}`) ) ); expect(pages.some(({ name }) => name.endsWith(" General Settings"))).toBe(false); expect(pages.some(({ name }) => name.endsWith(" Setup"))).toBe(false); const advanced = pages.find(({ name }) => name.endsWith(" Advanced")); expect(advanced?.items?.filter((item) => "type" in item && item.type === "group")).toHaveLength(4); expect(advanced?.items?.filter((item) => "action" in item && typeof item.action === "function")).toHaveLength( 1 ); expect(advanced?.page).toBeUndefined(); expect(pages.filter(({ page }) => page !== undefined)).toHaveLength(10); }); it("keeps simple setup actions on the landing page without a second Setup destination", () => { const tab = createSettingsTab(); const definitions = tab.getSettingDefinitions(); const quickSetup = definitions.find( (item): item is SettingDefinitionGroup => isGroup(item) && item.heading === "đŸ§™â€â™‚ī¸ Quick Setup" ); expect(quickSetup?.items?.map(({ name }) => name)).toEqual([ "Connect with Setup URI", "Rerun Onboarding Wizard", "Enable LiveSync", ]); expect(collectPages(definitions).some(({ name }) => name.endsWith(" Setup"))).toBe(false); }); it("constructs custom page state only when opened and disposes each rendered scope", () => { const tab = createSettingsTab(); const changeLog = findPage(tab, "Change Log"); if (!changeLog.page) { throw new Error("Change Log custom page is unavailable"); } expect(runtime.components).toHaveLength(0); const page = changeLog.page(); expect(runtime.components).toHaveLength(0); page.display(); expect(runtime.paneChangeLog).toHaveBeenCalledOnce(); expect(runtime.components).toHaveLength(1); expect(runtime.components[0].load).toHaveBeenCalledOnce(); page.display(); expect(runtime.components[0].unload).toHaveBeenCalledOnce(); expect(runtime.pageCleanup).toHaveBeenCalledOnce(); expect(runtime.components).toHaveLength(2); page.hide(); expect(runtime.components[1].unload).toHaveBeenCalledOnce(); expect(runtime.pageCleanup).toHaveBeenCalledTimes(2); expect(runtime.superHide).toHaveBeenCalledOnce(); }); it("does not run delayed pane work after its page scope has been disposed", async () => { runtime.paneChangeLog.mockImplementation(function ( this: ObsidianLiveSyncSettingTab, _paneEl: HTMLElement, { addPanel }: Pick ) { void addPanel(createElement(), "Delayed panel").then(() => { this.lifetimeComponent.register(runtime.pageCleanup); }); }); const tab = createSettingsTab(); const changeLog = findPage(tab, "Change Log"); if (!changeLog.page) { throw new Error("Change Log custom page is unavailable"); } const page = changeLog.page(); page.display(); page.hide(); await Promise.resolve(); expect(runtime.pageCleanup).not.toHaveBeenCalled(); }); it("runs a delayed pane callback inside its active scope before a queued hide", async () => { runtime.paneChangeLog.mockImplementation(function ( this: ObsidianLiveSyncSettingTab, _paneEl: HTMLElement, { addPanel }: Pick ) { void addPanel(createElement(), "Delayed panel").then(() => { this.lifetimeComponent.register(runtime.pageCleanup); }); }); const tab = createSettingsTab(); const changeLog = findPage(tab, "Change Log"); if (!changeLog.page) { throw new Error("Change Log custom page is unavailable"); } const page = changeLog.page(); page.display(); queueMicrotask(() => page.hide()); await Promise.resolve(); await Promise.resolve(); expect(runtime.pageCleanup).toHaveBeenCalledOnce(); }); it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => { const tab = createSettingsTab(); const changeLog = findPage(tab, "Change Log"); if (!changeLog.page) { throw new Error("Change Log custom page is unavailable"); } changeLog.page().display(); tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode; tab.requestReload(); expect(tab.update).toHaveBeenCalledOnce(); }); it("rebuilds the catalogue after an Extra menus feature level is saved", async () => { const tab = createSettingsTab(); tab.editingSettings.usePowerUserMode = true; await tab.saveSettings(["usePowerUserMode"]); expect(tab.update).toHaveBeenCalledOnce(); }); it("rebuilds translated catalogue names when the display language changes externally", () => { const tab = createSettingsTab(); const changeLog = findPage(tab, "Change Log"); if (!changeLog.page) { throw new Error("Change Log custom page is unavailable"); } changeLog.page().display(); tab.core.settings.displayLanguage = "ja"; tab.requestReload(); expect(tab.update).toHaveBeenCalledOnce(); }); it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => { const tab = createSettingsTab(); const changeLog = findPage(tab, "Change Log"); if (!changeLog.page) { throw new Error("Change Log custom page is unavailable"); } changeLog.page().display(); tab.initialSettings!.usePowerUserMode = false; tab.editingSettings.usePowerUserMode = true; tab.core.settings.usePowerUserMode = true; tab.requestReload(); const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2]; expect(configureAnchor).toBeTypeOf("function"); let acceptExternalSetting: (() => void) | undefined; configureAnchor?.({ text: "", addEventListener: vi.fn((_event: string, callback: () => void) => { acceptExternalSetting = callback; }), } as unknown as HTMLAnchorElement); expect(acceptExternalSetting).toBeTypeOf("function"); vi.mocked(tab.update).mockClear(); acceptExternalSetting!(); expect(tab.update).toHaveBeenCalledOnce(); }); it("does not reopen a custom page when a catalogue update has already hidden it", () => { const tab = createSettingsTab(); const changeLog = findPage(tab, "Change Log"); if (!changeLog.page) { throw new Error("Change Log custom page is unavailable"); } const page = changeLog.page(); page.display(); vi.mocked(tab.update).mockImplementation(() => page.hide()); tab.requestCatalogueRefresh(); expect(runtime.paneChangeLog).toHaveBeenCalledOnce(); }); it("keeps saved-setting effects owned by the tab after a custom page closes", async () => { runtime.paneChangeLog.mockImplementation(function (this: ObsidianLiveSyncSettingTab) { this.addOnSaved("displayLanguage", runtime.savedEffect); }); const tab = createSettingsTab(); const changeLog = findPage(tab, "Change Log"); if (!changeLog.page) { throw new Error("Change Log custom page is unavailable"); } const page = changeLog.page(); page.display(); page.hide(); tab.editingSettings.displayLanguage = "ja"; await tab.saveSettings(["displayLanguage"]); expect(runtime.savedEffect).toHaveBeenCalledOnce(); }); });