import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping"; import { DEFAULT_SETTINGS, normalizePluginSettings } from "@/application/config/PluginSettings"; const QA_GROUP_USER_MODEL_NAME = "问答题(多级列表)"; const { FakeElement, FakePluginSettingTab, FakeSetting, FakeResizeObserver, getLanguageMock, getResizeObserverInstances, resetResizeObserverInstances, } = vi.hoisted(() => { const hoistedGetLanguage = vi.fn(() => "zh"); const hoistedResizeObserverInstances: HoistedFakeResizeObserver[] = []; class HoistedFakeStyle { [key: string]: unknown; setProperty(name: string, value: string): void { this[name] = value; } getPropertyValue(name: string): string { const value = this[name]; return typeof value === "string" ? value : ""; } removeProperty(name: string): string { const value = this.getPropertyValue(name); delete this[name]; return value; } } class HoistedFakeClassList { private readonly values = new Set(); add(...tokens: string[]): void { for (const token of tokens) { if (token) { this.values.add(token); } } } remove(...tokens: string[]): void { for (const token of tokens) { this.values.delete(token); } } contains(token: string): boolean { return this.values.has(token); } toString(): string { return [...this.values].join(" "); } } class HoistedFakeElement { public readonly children: HoistedFakeElement[] = []; public readonly classList = new HoistedFakeClassList(); public readonly dataset: Record = {}; public readonly style = new HoistedFakeStyle(); public readonly ownedSettings: HoistedFakeSetting[] = []; public checked = false; public indeterminate = false; public disabled = false; public type = ""; public value = ""; public text = ""; public textContent = ""; public scrollTop = 0; private readonly listeners = new Map void | Promise>>(); private rectHeight = 0; constructor( public readonly root: HoistedFakeContainerEl, public readonly tag: string, public readonly parent: HoistedFakeElement | null = null, ) {} createEl(tag: string, options?: { text?: string }): HoistedFakeElement { const child = new HoistedFakeElement(this.root, tag, this); if (options?.text) { child.text = options.text; child.textContent = options.text; } this.children.push(child); return child; } createDiv(options?: { text?: string }): HoistedFakeElement { return this.createEl("div", options); } addEventListener(eventName: string, callback: () => void | Promise): void { const callbacks = this.listeners.get(eventName) ?? []; callbacks.push(callback); this.listeners.set(eventName, callbacks); } async trigger(eventName: string): Promise { for (const callback of this.listeners.get(eventName) ?? []) { await callback(); } } empty(): void { this.removeOwnedSettingsRecursively(); this.children.length = 0; this.text = ""; this.textContent = ""; } setAttr(name: string, value: string | number): this { (this as Record)[name] = value; return this; } setBoundingClientRect(rect: { height?: number }): void { if (typeof rect.height === "number") { this.rectHeight = rect.height; } } getBoundingClientRect(): DOMRect { return { bottom: this.rectHeight, height: this.rectHeight, left: 0, right: 0, toJSON: () => ({}), top: 0, width: 0, x: 0, y: 0, } as DOMRect; } private removeOwnedSettingsRecursively(): void { if (this.ownedSettings.length > 0) { this.root.settings = this.root.settings.filter((setting) => !this.ownedSettings.includes(setting)); this.ownedSettings.length = 0; } for (const child of this.children) { child.removeOwnedSettingsRecursively(); } } } class HoistedFakeContainerEl extends HoistedFakeElement { public settings: HoistedFakeSetting[] = []; public emptyCallCount = 0; constructor() { super(undefined as never, "root", null); (this as { root: HoistedFakeContainerEl }).root = this; } override empty(): void { this.emptyCallCount += 1; super.empty(); this.settings = []; } } class HoistedFakeButtonComponent { public text = ""; public readonly buttonEl = new HoistedFakeElement(new HoistedFakeContainerEl(), "button", null); private onClickHandler?: () => void | Promise; setButtonText(text: string): this { this.text = text; this.buttonEl.text = text; this.buttonEl.textContent = text; return this; } onClick(callback: () => void | Promise): this { this.onClickHandler = callback; return this; } async click(): Promise { await this.onClickHandler?.(); } } class HoistedFakeDropdownComponent { public options: Array<{ value: string; label: string }> = []; public value = ""; private onChangeHandler?: (value: string) => void | Promise; addOption(value: string, label: string): this { this.options.push({ value, label }); return this; } setValue(value: string): this { this.value = value; return this; } onChange(callback: (value: string) => void | Promise): this { this.onChangeHandler = callback; return this; } async triggerChange(value: string): Promise { this.value = value; await this.onChangeHandler?.(value); } } class HoistedFakeTextComponent { public value = ""; private onChangeHandler?: (value: string) => void | Promise; setPlaceholder(value: string): this { void value; return this; } setValue(value: string): this { this.value = value; return this; } onChange(callback: (value: string) => void | Promise): this { this.onChangeHandler = callback; return this; } async triggerChange(value: string): Promise { this.value = value; await this.onChangeHandler?.(value); } } class HoistedFakeToggleComponent { public value = false; public readonly toggleEl = new HoistedFakeElement(new HoistedFakeContainerEl(), "div", null); private onChangeHandler?: (value: boolean) => void | Promise; setValue(value: boolean): this { this.value = value; return this; } onChange(callback: (value: boolean) => void | Promise): this { this.onChangeHandler = callback; return this; } async triggerChange(value: boolean): Promise { this.value = value; await this.onChangeHandler?.(value); } } class HoistedFakeSetting { public name = ""; public desc: unknown = ""; public controls: Array< HoistedFakeButtonComponent | HoistedFakeDropdownComponent | HoistedFakeTextComponent | HoistedFakeToggleComponent > = []; constructor(containerEl: HoistedFakeElement) { containerEl.root.settings.push(this); containerEl.ownedSettings.push(this); } setName(name: string): this { this.name = name; return this; } setDesc(desc: unknown): this { this.desc = desc; return this; } addButton(callback: (button: HoistedFakeButtonComponent) => void): this { const button = new HoistedFakeButtonComponent(); this.controls.push(button); callback(button); return this; } addDropdown(callback: (dropdown: HoistedFakeDropdownComponent) => void): this { const dropdown = new HoistedFakeDropdownComponent(); this.controls.push(dropdown); callback(dropdown); return this; } addText(callback: (text: HoistedFakeTextComponent) => void): this { const text = new HoistedFakeTextComponent(); this.controls.push(text); callback(text); return this; } addToggle(callback: (toggle: HoistedFakeToggleComponent) => void): this { const toggle = new HoistedFakeToggleComponent(); this.controls.push(toggle); callback(toggle); return this; } } class HoistedFakePluginSettingTab { public containerEl = new HoistedFakeContainerEl(); constructor( public readonly app: unknown, public readonly plugin: unknown, ) {} hide(): void { return; } } class HoistedFakeResizeObserver { public readonly observedElements: HoistedFakeElement[] = []; public disconnected = false; constructor( private readonly callback: ( entries: Array<{ target: HoistedFakeElement }>, observer: HoistedFakeResizeObserver, ) => void, ) { hoistedResizeObserverInstances.push(this); } observe(target: HoistedFakeElement): void { this.observedElements.push(target); } disconnect(): void { this.disconnected = true; } trigger(): void { this.callback(this.observedElements.map((target) => ({ target })), this); } } return { FakeButtonComponent: HoistedFakeButtonComponent, FakeDropdownComponent: HoistedFakeDropdownComponent, FakeElement: HoistedFakeElement, FakePluginSettingTab: HoistedFakePluginSettingTab, FakeResizeObserver: HoistedFakeResizeObserver, FakeSetting: HoistedFakeSetting, getLanguageMock: hoistedGetLanguage, getResizeObserverInstances: () => hoistedResizeObserverInstances, resetResizeObserverInstances: () => { hoistedResizeObserverInstances.length = 0; }, }; }); vi.mock("obsidian", () => ({ getLanguage: getLanguageMock, PluginSettingTab: FakePluginSettingTab, Setting: FakeSetting, })); import { AnkiHeadingSyncSettingTab } from "./PluginSettingTab"; type FakeContainer = InstanceType["containerEl"]; type FakeSettingInstance = InstanceType; type FakeButtonInstance = { click(): Promise; buttonEl: FakeElementInstance }; type FakeToggleInstance = { value: boolean; triggerChange(value: boolean): Promise; toggleEl: FakeElementInstance }; type FakeElementInstance = InstanceType; type QueryRoot = FakeContainer | FakeElementInstance | HTMLElement; class FakePlugin { public readonly app = {}; public readonly updateCalls: Array> = []; public readonly fieldNamesByModelName: Record = {}; public listNoteModelsCalls = 0; public getModelFieldNamesByModelNamesCalls = 0; public getNoteModelDetailsCalls = 0; public listFolderTreeCalls = 0; public insertDeckTemplateCalls = 0; public listNoteModelsError: Error | null = null; public noteModels = ["Basic", "Custom Basic", "Cloze", "Custom Cloze", "Semantic QA", QA_GROUP_USER_MODEL_NAME]; public settings = normalizePluginSettings({ ...DEFAULT_SETTINGS, qaNoteType: "Custom Basic", clozeNoteType: "Custom Cloze", cardTypeConfigs: { ...DEFAULT_SETTINGS.cardTypeConfigs, basic: { ...DEFAULT_SETTINGS.cardTypeConfigs.basic, noteType: "Custom Basic", }, cloze: { ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, noteType: "Custom Cloze", }, }, noteFieldMappings: {}, }); public folderTree = [ { path: "notes", name: "notes", children: [ { path: "notes/sub", name: "sub", children: [], }, { path: "notes/other", name: "other", children: [], }, ], }, ]; async updateSettings(partialSettings: Record): Promise { this.updateCalls.push(partialSettings); this.settings = normalizePluginSettings({ ...this.settings, ...partialSettings, cardTypeConfigs: (partialSettings.cardTypeConfigs as typeof this.settings.cardTypeConfigs | undefined) ?? this.settings.cardTypeConfigs, noteFieldMappings: (partialSettings.noteFieldMappings as typeof this.settings.noteFieldMappings | undefined) ?? this.settings.noteFieldMappings, }); } async listNoteModels(): Promise { this.listNoteModelsCalls += 1; if (this.listNoteModelsError) { throw this.listNoteModelsError; } return [...this.noteModels]; } async getModelFieldNamesByModelNames(modelNames: string[]): Promise> { this.getModelFieldNamesByModelNamesCalls += 1; return modelNames.reduce>((fieldNamesByModelName, modelName) => { fieldNamesByModelName[modelName] = this.getFieldNamesForModel(modelName); return fieldNamesByModelName; }, {}); } async getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[] }> { this.getNoteModelDetailsCalls += 1; return { fieldNames: this.getFieldNamesForModel(modelName), }; } async listFolderTree(): Promise { this.listFolderTreeCalls += 1; return this.folderTree; } async insertDeckTemplateToCurrentFile(): Promise { this.insertDeckTemplateCalls += 1; } private getFieldNamesForModel(modelName: string): string[] { if (this.fieldNamesByModelName[modelName]) { return this.fieldNamesByModelName[modelName]; } if (modelName.includes("Cloze")) { return ["Text", "Extra", "Hint"]; } if (modelName === "Semantic QA") { return ["Title", "Body", "Source"]; } if (modelName === "Basic") { return ["Front", "Back", "Extra"]; } if (modelName === QA_GROUP_USER_MODEL_NAME) { return ["题目", "标题", "问题01", "答案01", "问题02", "答案02"]; } return ["Title", "Body", "Hint"]; } } function asFakeContainer(container: QueryRoot): FakeContainer { return container as unknown as FakeContainer; } function findElement(container: QueryRoot, predicate: (element: InstanceType) => boolean): InstanceType | undefined { const stack = [...asFakeContainer(container).children]; while (stack.length > 0) { const current = stack.shift(); if (!current) { continue; } if (predicate(current)) { return current; } stack.unshift(...current.children); } return undefined; } function findElements(container: QueryRoot, predicate: (element: InstanceType) => boolean): InstanceType[] { const matches: InstanceType[] = []; const stack = [...asFakeContainer(container).children]; while (stack.length > 0) { const current = stack.shift(); if (!current) { continue; } if (predicate(current)) { matches.push(current); } stack.unshift(...current.children); } return matches; } function queryByDataset(container: QueryRoot, key: string, value: string): InstanceType { const element = findElement(container, (candidate) => candidate.dataset[key] === value); if (!element) { throw new Error(`Element not found for data-${key}=${value}`); } return element; } function queryAllByDataset(container: QueryRoot, key: string): InstanceType[] { return findElements(container, (candidate) => key in candidate.dataset); } function findSetting(container: QueryRoot, name: string): FakeSettingInstance { const setting = asFakeContainer(container).settings.find((candidate) => candidate.name === name); if (!setting) { throw new Error(`Setting not found: ${name}`); } return setting; } function getToggle(setting: FakeSettingInstance): FakeToggleInstance { const toggle = setting.controls.find((control) => typeof control === "object" && control !== null && "triggerChange" in control && "value" in control && typeof (control as { value?: unknown }).value === "boolean"); if (!toggle) { throw new Error(`Toggle not found for setting: ${setting.name}`); } return toggle as FakeToggleInstance; } function getButton(setting: FakeSettingInstance): FakeButtonInstance { const button = setting.controls.find((control) => typeof control === "object" && control !== null && "click" in control && "buttonEl" in control); if (!button) { throw new Error(`Button not found for setting: ${setting.name}`); } return button as FakeButtonInstance; } function collectTexts(container: QueryRoot): string[] { return findElements(container, () => true) .map((element) => element.textContent || element.text) .filter((text): text is string => Boolean(text)); } function collectSettingNames(container: QueryRoot): string[] { return asFakeContainer(container).settings.map((setting) => setting.name); } function collectOwnedSettingNames(container: FakeElementInstance): string[] { return container.ownedSettings.map((setting) => setting.name); } function collectOwnedSettingDescs(container: FakeElementInstance): string[] { return container.ownedSettings .map((setting) => setting.desc) .filter((desc): desc is string => typeof desc === "string"); } function collectOptionValues(selectEl: FakeElementInstance): string[] { return selectEl.children .filter((element) => element.tag === "option") .map((element) => element.value); } function getStyleProperty(element: { style: { getPropertyValue?: (name: string) => string } }, property: string): string { const style = element.style as { getPropertyValue?: (name: string) => string; [key: string]: unknown }; if (typeof style.getPropertyValue === "function") { return style.getPropertyValue(property); } const value = style[property]; return typeof value === "string" ? value : ""; } function getEmptyCallCount(container: QueryRoot): number { return asFakeContainer(container).emptyCallCount; } async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); } async function expandCard(tab: AnkiHeadingSyncSettingTab, cardId: string): Promise { if (queryByDataset(tab.containerEl, "settingsCardBody", cardId).style.display === "block") { return; } await queryByDataset(tab.containerEl, "settingsCardToggle", cardId).trigger("click"); } describe("PluginSettingTab", () => { const originalResizeObserver = globalThis.ResizeObserver; beforeEach(() => { getLanguageMock.mockReturnValue("zh"); vi.useRealTimers(); resetResizeObserverInstances(); Reflect.set(globalThis, "ResizeObserver", FakeResizeObserver); }); afterEach(() => { vi.useRealTimers(); if (typeof originalResizeObserver === "undefined") { Reflect.deleteProperty(globalThis, "ResizeObserver"); return; } Reflect.set(globalThis, "ResizeObserver", originalResizeObserver); }); it("renders five cards collapsed by default", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); expect(tab.containerEl.classList.contains("anki-heading-sync-settings")).toBe(true); expect(queryByDataset(tab.containerEl, "settingsPageHeader", "true")).toBeDefined(); const cards = queryAllByDataset(tab.containerEl, "settingsCard"); expect(cards).toHaveLength(5); expect(queryByDataset(tab.containerEl, "settingsCardBody", "sync-content").style.display).toBe("none"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("none"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "commands").style.display).toBe("none"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none"); expect(cards.map((card) => card.dataset.settingsCard)).toEqual(["card-types", "sync-content", "deck", "scope", "commands"]); for (const cardId of ["card-types", "sync-content", "deck", "scope", "commands"] as const) { expect(queryByDataset(tab.containerEl, "settingsCardToggle", cardId).textContent.startsWith("▸ ")).toBe(true); } }); it("expands only the clicked card while others remain collapsed", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("block"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("none"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "sync-content").style.display).toBe("none"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "commands").style.display).toBe("none"); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none"); }); it("applies sticky styles to the opaque page header gap, mask, and card headers", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); const pageHeader = queryByDataset(tab.containerEl, "settingsPageHeader", "true"); const pageHeaderMask = queryByDataset(pageHeader, "settingsPageHeaderMask", "true"); expect(pageHeader.style.position).toBe("sticky"); expect(pageHeader.style.top).toBe("0px"); expect(pageHeader.style.zIndex).toBe("300"); expect(pageHeader.style.width).toBe("100%"); expect(pageHeader.style.boxSizing).toBe("border-box"); expect(pageHeader.style.marginBottom).toBe("0"); expect(pageHeader.style.paddingBottom).toBe("calc(12px + var(--ahs-settings-sticky-card-gap, 8px))"); expect(pageHeader.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))"); expect(pageHeader.style.boxShadow).toBe("none"); expect(collectTexts(pageHeader)).toContain("Anki Heading Sync"); expect(pageHeaderMask.style.position).toBe("absolute"); expect(pageHeaderMask.style.top).toBe("-128px"); expect(pageHeaderMask.style.left).toBe("-24px"); expect(pageHeaderMask.style.right).toBe("-24px"); expect(pageHeaderMask.style.bottom).toBe("0px"); expect(pageHeaderMask.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))"); expect(getStyleProperty(tab.containerEl, "--ahs-settings-page-header-height")).toBe("64px"); expect(getStyleProperty(tab.containerEl, "--ahs-settings-sticky-card-gap")).toBe("8px"); for (const cardId of ["card-types", "sync-content", "deck", "scope", "commands"] as const) { const header = queryByDataset(tab.containerEl, "settingsCardToggle", cardId); expect(header.style.position).toBe("sticky"); expect(header.style.top).toBe("var(--ahs-settings-page-header-height, 64px)"); expect(header.style.zIndex).toBe("200"); expect(header.style.display).toBe("flex"); expect(header.style.width).toBe("100%"); expect(header.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))"); expect(header.style.fontSize).toBe("1.5em"); expect(header.style.fontWeight).toBe("600"); expect(header.style.border).toBe("2px solid var(--background-modifier-border)"); expect(header.style.boxShadow).toBe("none"); } }); it("updates the page header height variable from ResizeObserver and disconnects it on hide", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); const pageHeader = queryByDataset(tab.containerEl, "settingsPageHeader", "true"); pageHeader.setBoundingClientRect({ height: 72 }); const resizeObserver = getResizeObserverInstances().at(0); expect(resizeObserver).toBeDefined(); resizeObserver?.trigger(); expect(getStyleProperty(tab.containerEl, "--ahs-settings-page-header-height")).toBe("72px"); tab.hide(); expect(resizeObserver?.disconnected).toBe(true); }); it("renders card 1 as three readable card type blocks", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); expect(queryByDataset(tab.containerEl, "cardTypeList", "true")).toBeDefined(); expect(findElements(tab.containerEl, (element) => element.tag === "th")).toHaveLength(0); expect(() => findSetting(tab.containerEl, "AnkiConnect URL")).toThrow("Setting not found"); const rowLabels = findElements(tab.containerEl, (element) => element.dataset.cardTypeConfig !== undefined) .map((row) => collectTexts(row).find((text) => text.includes("题"))); expect(rowLabels).toEqual([ "问答题(常规段落形式)", "问答题(多级列表形式)", "填空题", ]); expect(() => queryByDataset(tab.containerEl, "cardTypeMarker", "semantic-qa")).toThrow("Element not found"); }); it("does not render the legacy card-types description copy", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); expect(collectTexts(tab.containerEl)).not.toContain("直接编辑识别规则,修改后自动保存。"); }); it("renders sync-content section titles and settings in the new grouped order", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "sync-content"); expect(queryByDataset(tab.containerEl, "settingsCardToggle", "sync-content").textContent).toContain("卡片正文同步内容"); expect(collectTexts(tab.containerEl)).not.toContain("这些选项只影响卡片渲染内容,修改后仅刷新这一张卡片。"); const sectionTitles = queryAllByDataset(tab.containerEl, "syncContentSectionTitle").map((element) => element.textContent || element.text); expect(sectionTitles).toEqual([ "1. 确定「卡片正文」范围", "2. 确定是否增加回链,方便从 Anki「卡片级跳转」回 Obsidian", "3. 确定是否读取「标签」", "4. 「填空题」专项", ]); expect(collectSettingNames(tab.containerEl)).toEqual([ "卡片正文截止模式", "添加 Obsidian 回链", "Obsidian 回链显示名称", "Obsidian 回链放置位置", "同步 Obsidian 标签到 Anki", "在卡片正文中保留纯标签行", "高亮转填空题", ]); expect(findSetting(tab.containerEl, "高亮转填空题")).toBeDefined(); expect(() => findSetting(tab.containerEl, "高亮转 Cloze")).toThrow("Setting not found"); }); it("keeps saving highlights-to-cloze through the same internal config field", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "sync-content"); const highlightsSetting = findSetting(tab.containerEl, "高亮转填空题"); await getToggle(highlightsSetting).triggerChange(false); expect(plugin.settings.convertHighlightsToCloze).toBe(false); expect(plugin.updateCalls).toContainEqual({ convertHighlightsToCloze: false }); }); it("applies the themed class only to settings action buttons", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, scopeMode: "include", }); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); await expandCard(tab, "scope"); await flushPromises(); expect(queryByDataset(tab.containerEl, "cardTypesRefresh", "true").classList.contains("ahs-theme-action-button")).toBe(true); expect(queryByDataset(tab.containerEl, "scopeRefreshFolders", "true").classList.contains("ahs-theme-action-button")).toBe(true); const cardHeaderButton = queryByDataset(tab.containerEl, "settingsCardToggle", "card-types"); expect(cardHeaderButton.classList.contains("ahs-theme-action-button")).toBe(false); const folderToggleButton = queryByDataset(tab.containerEl, "folderToggle", "notes"); expect(folderToggleButton.classList.contains("ahs-theme-action-button")).toBe(false); await expandCard(tab, "deck"); const fileDeckSetting = findSetting(tab.containerEl, "开启文件级自定义牌组"); await getToggle(fileDeckSetting).triggerChange(true); const insertTemplateSetting = findSetting(tab.containerEl, "向当前文件插入牌组模板"); const insertTemplateButton = getButton(insertTemplateSetting); expect(insertTemplateButton.buttonEl.classList.contains("ahs-theme-action-button")).toBe(true); await insertTemplateButton.click(); expect(plugin.insertDeckTemplateCalls).toBe(1); }); it("marks every settings toggle with theme state and updates the dataset on change", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "sync-content"); await expandCard(tab, "deck"); const toggleSettings = [ findSetting(tab.containerEl, "添加 Obsidian 回链"), findSetting(tab.containerEl, "同步 Obsidian 标签到 Anki"), findSetting(tab.containerEl, "在卡片正文中保留纯标签行"), findSetting(tab.containerEl, "高亮转填空题"), findSetting(tab.containerEl, "开启文件级自定义牌组"), ]; for (const setting of toggleSettings) { const toggle = getToggle(setting); expect(toggle.toggleEl.classList.contains("ahs-theme-toggle")).toBe(true); expect(toggle.toggleEl.dataset.ahsToggleState).toBe(toggle.value ? "on" : "off"); } const highlightsToggle = getToggle(findSetting(tab.containerEl, "高亮转填空题")); const nextValue = !highlightsToggle.value; await highlightsToggle.triggerChange(nextValue); expect(highlightsToggle.toggleEl.dataset.ahsToggleState).toBe(nextValue ? "on" : "off"); }); it("auto saves toggle, heading, note type and field mapping edits", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); await flushPromises(); const basicToggle = queryByDataset(tab.containerEl, "cardTypeEnabled", "basic"); basicToggle.checked = false; await basicToggle.trigger("change"); expect(plugin.settings.cardTypeConfigs.basic.enabled).toBe(false); const clozeHeading = queryByDataset(tab.containerEl, "cardTypeHeading", "cloze"); clozeHeading.value = "6"; await clozeHeading.trigger("change"); expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(6); const basicNoteType = queryByDataset(tab.containerEl, "cardTypeNoteType", "basic"); basicNoteType.value = "Basic"; await basicNoteType.trigger("change"); expect(plugin.settings.cardTypeConfigs.basic.noteType).toBe("Basic"); const qaGroupNoteType = queryByDataset(tab.containerEl, "cardTypeNoteType", "qa-group"); qaGroupNoteType.value = QA_GROUP_USER_MODEL_NAME; await qaGroupNoteType.trigger("change"); expect(plugin.settings.cardTypeConfigs["qa-group"].noteType).toBe(QA_GROUP_USER_MODEL_NAME); await flushPromises(); const basicQuestionField = queryByDataset(tab.containerEl, "cardTypeQuestionField", "basic"); basicQuestionField.value = "Hint"; await basicQuestionField.trigger("change"); const basicAnswerField = queryByDataset(tab.containerEl, "cardTypeAnswerField", "basic"); basicAnswerField.value = "Body"; await basicAnswerField.trigger("change"); expect(plugin.settings.noteFieldMappings[createNoteFieldMappingKey("basic", plugin.settings.cardTypeConfigs.basic.noteType)]).toEqual(expect.objectContaining({ titleField: "Hint", bodyField: "Body", })); const qaGroupTitleField = queryByDataset(tab.containerEl, "qaGroupTitleField", "qa-group"); expect(qaGroupTitleField.value).toBe("题目"); expect(queryByDataset(tab.containerEl, "qaGroupFirstQuestionField", "qa-group").value).toBe("问题01"); expect(queryByDataset(tab.containerEl, "qaGroupFirstAnswerField", "qa-group").value).toBe("答案01"); qaGroupTitleField.value = "标题"; await qaGroupTitleField.trigger("change"); expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已配置2组"); expect(plugin.settings.noteFieldMappings[createNoteFieldMappingKey("qa-group", plugin.settings.cardTypeConfigs["qa-group"].noteType)]).toEqual(expect.objectContaining({ titleField: "标题", derivation: { mode: "first-pair", firstQuestionField: "问题01", firstAnswerField: "答案01", }, slots: [ { index: 1, questionField: "问题01", answerField: "答案01" }, { index: 2, questionField: "问题02", answerField: "答案02" }, ], warnings: [], })); }); it("debounces text input saves instead of saving every keystroke", async () => { vi.useFakeTimers(); const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); const markerInput = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze"); markerInput.value = "#cloze-a"; await markerInput.trigger("input"); markerInput.value = "#cloze-ab"; await markerInput.trigger("input"); expect(plugin.updateCalls).toHaveLength(0); vi.advanceTimersByTime(499); await flushPromises(); expect(plugin.updateCalls).toHaveLength(0); vi.advanceTimersByTime(1); await flushPromises(); expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-ab"); expect(plugin.updateCalls).toHaveLength(1); }); it("blocks conflicting default rows and shows the error in card 1", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); const clozeHeading = queryByDataset(tab.containerEl, "cardTypeHeading", "cloze"); clozeHeading.value = "4"; await clozeHeading.trigger("change"); vi.useFakeTimers(); const clozeMarker = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze"); clozeMarker.value = ""; await clozeMarker.trigger("input"); vi.advanceTimersByTime(500); await flushPromises(); vi.useRealTimers(); expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(4); expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#anki-cloze"); expect(collectTexts(tab.containerEl).some((text) => text.includes("同一个 H4 只能有一个启用的默认卡片类型"))).toBe(true); }); it("expanding and collapsing cards does not rebuild the whole settings page", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); const initialEmptyCount = getEmptyCallCount(tab.containerEl); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("block"); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none"); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); }); it("loading Anki config refreshes only card 1 instead of rebuilding the whole page", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); const initialEmptyCount = getEmptyCallCount(tab.containerEl); await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); await flushPromises(); expect(plugin.listNoteModelsCalls).toBe(1); expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(1); expect(plugin.getNoteModelDetailsCalls).toBe(0); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 6 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true); }); it("persists loaded Anki note types and uses the cache before the next manual refresh", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); await flushPromises(); expect(plugin.settings.ankiNoteTypeCache).toEqual([ "Basic", "Cloze", "Custom Basic", "Custom Cloze", "Semantic QA", QA_GROUP_USER_MODEL_NAME, ].sort((left, right) => left.localeCompare(right))); expect(plugin.updateCalls.some((call) => Array.isArray(call.ankiNoteTypeCache))).toBe(true); expect(plugin.settings.ankiModelFieldCache).toEqual(expect.objectContaining({ Basic: { fieldNames: ["Front", "Back", "Extra"], loadedAt: expect.any(Number), }, "Custom Basic": { fieldNames: ["Title", "Body", "Hint"], loadedAt: expect.any(Number), }, Cloze: { fieldNames: ["Text", "Extra", "Hint"], loadedAt: expect.any(Number), }, })); expect(plugin.updateCalls.some((call) => typeof call.ankiModelFieldCache === "object" && call.ankiModelFieldCache !== null)).toBe(true); const cachedPlugin = new FakePlugin(); cachedPlugin.settings = normalizePluginSettings({ ...cachedPlugin.settings, ankiNoteTypeCache: ["Basic", "Cached Basic", "Cached Cloze"], ankiModelFieldCache: { "Cached Basic": { fieldNames: ["Front", "Back", "Hint"], loadedAt: 123, }, }, cardTypeConfigs: { ...cachedPlugin.settings.cardTypeConfigs, basic: { ...cachedPlugin.settings.cardTypeConfigs.basic, noteType: "Cached Basic", }, }, }); cachedPlugin.noteModels = ["Basic", "Cached Basic", "Cached Cloze"]; const cachedTab = new AnkiHeadingSyncSettingTab(cachedPlugin as never); cachedTab.display(); await expandCard(cachedTab, "card-types"); await flushPromises(); const basicNoteTypeSelect = queryByDataset(cachedTab.containerEl, "cardTypeNoteType", "basic"); expect(collectOptionValues(basicNoteTypeSelect)).toEqual(["Basic", "Cached Basic", "Cached Cloze"]); const basicQuestionFieldSelect = queryByDataset(cachedTab.containerEl, "cardTypeQuestionField", "basic"); const basicAnswerFieldSelect = queryByDataset(cachedTab.containerEl, "cardTypeAnswerField", "basic"); expect(collectOptionValues(basicQuestionFieldSelect)).toEqual(["", "Front", "Back", "Hint"]); expect(collectOptionValues(basicAnswerFieldSelect)).toEqual(["", "Front", "Back", "Hint"]); expect(cachedPlugin.listNoteModelsCalls).toBe(1); expect(cachedPlugin.getModelFieldNamesByModelNamesCalls).toBe(0); expect(cachedPlugin.getNoteModelDetailsCalls).toBe(0); expect(collectTexts(cachedTab.containerEl).some((text) => text.includes("当前使用缓存的 3 个笔记模板,已选择并配置 1 个卡片模式。"))).toBe(true); }); it("shows cache-empty status and skips background checking when there is no cached note type", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); expect(plugin.listNoteModelsCalls).toBe(0); expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(0); expect(collectTexts(tab.containerEl).some((text) => text.includes("当前没有已缓存的 Anki 笔记模板"))).toBe(true); }); it("warns when the live note type list differs from the cached list without loading fields", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, ankiNoteTypeCache: ["Basic", "Cached Basic", "Cached Cloze"], ankiModelFieldCache: { "Cached Basic": { fieldNames: ["Front", "Back", "Hint"], loadedAt: 123, }, }, cardTypeConfigs: { ...plugin.settings.cardTypeConfigs, basic: { ...plugin.settings.cardTypeConfigs.basic, noteType: "Cached Basic", }, }, }); plugin.noteModels = ["Basic", "Cached Basic", "Cached Cloze", "Live Only"]; const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); await flushPromises(); expect(plugin.listNoteModelsCalls).toBe(1); expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(0); expect(plugin.getNoteModelDetailsCalls).toBe(0); expect(collectTexts(tab.containerEl).some((text) => text.includes("和本页缓存的 3 个模板不一致"))).toBe(true); }); it("shows cache-check failure while keeping cached dropdowns when the lightweight check fails", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, ankiNoteTypeCache: ["Basic", "Cached Basic"], ankiModelFieldCache: { "Cached Basic": { fieldNames: ["Front", "Back", "Hint"], loadedAt: 123, }, }, cardTypeConfigs: { ...plugin.settings.cardTypeConfigs, basic: { ...plugin.settings.cardTypeConfigs.basic, noteType: "Cached Basic", }, }, }); plugin.listNoteModelsError = new Error("offline"); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); await flushPromises(); expect(plugin.listNoteModelsCalls).toBe(1); expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(0); expect(collectTexts(tab.containerEl).some((text) => text.includes("后台检查最新模板列表失败"))).toBe(true); expect(collectOptionValues(queryByDataset(tab.containerEl, "cardTypeNoteType", "basic"))).toEqual(["Basic", "Cached Basic"]); }); it("manual refresh overrides the stale-cache warning with the latest loaded summary", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, ankiNoteTypeCache: ["Basic", "Cached Basic"], ankiModelFieldCache: { "Cached Basic": { fieldNames: ["Front", "Back", "Hint"], loadedAt: 123, }, }, cardTypeConfigs: { ...plugin.settings.cardTypeConfigs, basic: { ...plugin.settings.cardTypeConfigs.basic, noteType: "Cached Basic", }, }, }); plugin.noteModels = ["Basic", "Cached Basic", "Custom Cloze"]; const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); await flushPromises(); expect(collectTexts(tab.containerEl).some((text) => text.includes("和本页缓存的 2 个模板不一致"))).toBe(true); await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); await flushPromises(); expect(plugin.listNoteModelsCalls).toBe(2); expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(1); expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 3 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true); expect(collectTexts(tab.containerEl).some((text) => text.includes("和本页缓存的 2 个模板不一致"))).toBe(false); }); it("counts only enabled visible card types with cached fields in the status summary", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, ankiNoteTypeCache: ["Basic", "Custom Cloze", QA_GROUP_USER_MODEL_NAME], ankiModelFieldCache: { Basic: { fieldNames: ["Front", "Back", "Extra"], loadedAt: 100, }, "Custom Cloze": { fieldNames: ["Text", "Extra", "Hint"], loadedAt: 100, }, [QA_GROUP_USER_MODEL_NAME]: { fieldNames: ["题目", "标题", "问题01", "答案01"], loadedAt: 100, }, }, cardTypeConfigs: { ...plugin.settings.cardTypeConfigs, basic: { ...plugin.settings.cardTypeConfigs.basic, enabled: true, noteType: "Basic", }, "qa-group": { ...plugin.settings.cardTypeConfigs["qa-group"], enabled: true, noteType: QA_GROUP_USER_MODEL_NAME, }, cloze: { ...plugin.settings.cardTypeConfigs.cloze, enabled: false, noteType: "Custom Cloze", }, }, }); plugin.noteModels = ["Basic", "Custom Cloze", QA_GROUP_USER_MODEL_NAME]; const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); await flushPromises(); expect(collectTexts(tab.containerEl).some((text) => text.includes("当前使用缓存的 3 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true); const clozeToggle = queryByDataset(tab.containerEl, "cardTypeEnabled", "cloze"); clozeToggle.checked = true; await clozeToggle.trigger("change"); expect(collectTexts(tab.containerEl).some((text) => text.includes("当前使用缓存的 3 个笔记模板,已选择并配置 3 个卡片模式。"))).toBe(true); }); it("excludes enabled card types without cached fields from the configured count", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, ankiNoteTypeCache: ["Basic", "Custom Cloze", QA_GROUP_USER_MODEL_NAME], ankiModelFieldCache: { Basic: { fieldNames: ["Front", "Back", "Extra"], loadedAt: 100, }, [QA_GROUP_USER_MODEL_NAME]: { fieldNames: ["题目", "标题", "问题01", "答案01"], loadedAt: 100, }, }, cardTypeConfigs: { ...plugin.settings.cardTypeConfigs, basic: { ...plugin.settings.cardTypeConfigs.basic, enabled: true, noteType: "Basic", }, "qa-group": { ...plugin.settings.cardTypeConfigs["qa-group"], enabled: true, noteType: QA_GROUP_USER_MODEL_NAME, }, cloze: { ...plugin.settings.cardTypeConfigs.cloze, enabled: true, noteType: "Custom Cloze", }, }, }); plugin.noteModels = ["Basic", "Custom Cloze", QA_GROUP_USER_MODEL_NAME]; const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); await flushPromises(); expect(collectTexts(tab.containerEl).some((text) => text.includes("当前使用缓存的 3 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true); }); it("switching note type immediately reuses cached field names", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, ankiNoteTypeCache: ["Basic", "Custom Basic"], ankiModelFieldCache: { Basic: { fieldNames: ["Front", "Back", "Extra"], loadedAt: 100, }, "Custom Basic": { fieldNames: ["Title", "Body", "Hint"], loadedAt: 200, }, }, cardTypeConfigs: { ...plugin.settings.cardTypeConfigs, basic: { ...plugin.settings.cardTypeConfigs.basic, noteType: "Custom Basic", }, }, }); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); const basicNoteTypeSelect = queryByDataset(tab.containerEl, "cardTypeNoteType", "basic"); basicNoteTypeSelect.value = "Basic"; await basicNoteTypeSelect.trigger("change"); await flushPromises(); const basicQuestionFieldSelect = queryByDataset(tab.containerEl, "cardTypeQuestionField", "basic"); const basicAnswerFieldSelect = queryByDataset(tab.containerEl, "cardTypeAnswerField", "basic"); expect(collectOptionValues(basicQuestionFieldSelect)).toEqual(["", "Front", "Back", "Extra"]); expect(collectOptionValues(basicAnswerFieldSelect)).toEqual(["", "Front", "Back", "Extra"]); expect(plugin.settings.noteFieldMappings[createNoteFieldMappingKey("basic", "Basic")]).toEqual(expect.objectContaining({ titleField: "Front", bodyField: "Back", loadedFieldNames: ["Front", "Back", "Extra"], })); expect(plugin.getNoteModelDetailsCalls).toBe(0); }); it("extends saved qa-group slots after field refresh only when derivation metadata exists", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, ankiNoteTypeCache: [QA_GROUP_USER_MODEL_NAME], ankiModelFieldCache: { [QA_GROUP_USER_MODEL_NAME]: { fieldNames: ["题目", "问题01", "答案01", "问题02"], loadedAt: 100, }, }, cardTypeConfigs: { ...plugin.settings.cardTypeConfigs, "qa-group": { ...plugin.settings.cardTypeConfigs["qa-group"], noteType: QA_GROUP_USER_MODEL_NAME, }, }, noteFieldMappings: { ...plugin.settings.noteFieldMappings, [createNoteFieldMappingKey("qa-group", QA_GROUP_USER_MODEL_NAME)]: { cardType: "qa-group", modelName: QA_GROUP_USER_MODEL_NAME, loadedFieldNames: ["题目", "问题01", "答案01", "问题02"], titleField: "题目", derivation: { mode: "first-pair", firstQuestionField: "问题01", firstAnswerField: "答案01", }, slots: [ { index: 1, questionField: "问题01", answerField: "答案01" }, ], warnings: [], loadedAt: 1, }, }, }); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); const mappingKey = createNoteFieldMappingKey("qa-group", QA_GROUP_USER_MODEL_NAME); expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已配置1组"); plugin.fieldNamesByModelName[QA_GROUP_USER_MODEL_NAME] = ["题目", "问题01", "答案01", "问题02", "答案02", "问题03", "答案03"]; await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); await flushPromises(); expect(plugin.settings.noteFieldMappings[mappingKey]).toEqual(expect.objectContaining({ derivation: { mode: "first-pair", firstQuestionField: "问题01", firstAnswerField: "答案01", }, slots: [ { index: 1, questionField: "问题01", answerField: "答案01" }, { index: 2, questionField: "问题02", answerField: "答案02" }, { index: 3, questionField: "问题03", answerField: "答案03" }, ], })); expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已配置3组"); }); it("keeps the saved qa-group title field and slots when reading Anki fields again", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, ankiNoteTypeCache: [QA_GROUP_USER_MODEL_NAME], ankiModelFieldCache: { [QA_GROUP_USER_MODEL_NAME]: { fieldNames: ["题目", "问题01", "答案01", "Titre", "QPerso", "APerso"], loadedAt: 100, }, }, cardTypeConfigs: { ...plugin.settings.cardTypeConfigs, "qa-group": { ...plugin.settings.cardTypeConfigs["qa-group"], noteType: QA_GROUP_USER_MODEL_NAME, }, }, noteFieldMappings: { ...plugin.settings.noteFieldMappings, [createNoteFieldMappingKey("qa-group", QA_GROUP_USER_MODEL_NAME)]: { cardType: "qa-group", modelName: QA_GROUP_USER_MODEL_NAME, loadedFieldNames: ["Titre", "QPerso", "APerso"], titleField: "Titre", slots: [ { index: 1, questionField: "QPerso", answerField: "APerso" }, ], warnings: [], loadedAt: 1, }, }, }); plugin.fieldNamesByModelName[QA_GROUP_USER_MODEL_NAME] = ["题目", "问题01", "答案01", "Titre", "QPerso", "APerso"]; const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await expandCard(tab, "card-types"); await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); await flushPromises(); const mappingKey = createNoteFieldMappingKey("qa-group", QA_GROUP_USER_MODEL_NAME); expect(plugin.settings.noteFieldMappings[mappingKey]).toEqual(expect.objectContaining({ titleField: "Titre", slots: [ { index: 1, questionField: "QPerso", answerField: "APerso" }, ], loadedFieldNames: ["题目", "问题01", "答案01", "Titre", "QPerso", "APerso"], })); expect(queryByDataset(tab.containerEl, "qaGroupTitleField", "qa-group").value).toBe("Titre"); expect(queryByDataset(tab.containerEl, "qaGroupFirstQuestionField", "qa-group").value).toBe("QPerso"); expect(queryByDataset(tab.containerEl, "qaGroupFirstAnswerField", "qa-group").value).toBe("APerso"); expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已配置1组"); }); it("renders the scope card with renamed copy and aligned folder tree rows", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, scopeMode: "include", includeFolders: ["notes/sub"], }); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); expect(queryByDataset(tab.containerEl, "settingsCardToggle", "scope").textContent).toContain("插件运行范围"); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); await flushPromises(); expect(findSetting(tab.containerEl, "插件运行范围")).toBeDefined(); expect(() => findSetting(tab.containerEl, "运行范围")).toThrow("Setting not found"); expect(collectTexts(tab.containerEl)).not.toContain("保留现有作用范围和文件夹树逻辑;文件夹树按需加载,并支持局部刷新。"); const parentRow = queryByDataset(tab.containerEl, "folderRow", "notes"); const parentToggle = queryByDataset(tab.containerEl, "folderToggle", "notes"); const childRow = queryByDataset(tab.containerEl, "folderRow", "notes/sub"); const childToggle = queryByDataset(tab.containerEl, "folderToggle", "notes/sub"); const childCheckbox = queryByDataset(tab.containerEl, "folderPath", "notes/sub"); const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub"); expect(parentToggle.textContent).toBe("▾"); expect(parentRow.style.display).toBe("grid"); expect(parentRow.style.gridTemplateColumns).toBe("24px 24px minmax(0, 1fr)"); expect(parentRow.style.alignItems).toBe("center"); expect(childRow.style.marginLeft).toBe("18px"); expect(childToggle.style.width).toBe("24px"); expect(childToggle.style.height).toBe("24px"); expect(childToggle.style.fontSize).toBe("2em"); expect(childCheckbox).toBeDefined(); expect(childLabel.style.whiteSpace).toBe("nowrap"); expect(childLabel.style.overflow).toBe("hidden"); expect(childLabel.style.textOverflow).toBe("ellipsis"); }); it("folder tree expand and check refresh only the scope card", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ ...plugin.settings, scopeMode: "include", }); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); const initialEmptyCount = getEmptyCallCount(tab.containerEl); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); await flushPromises(); expect(plugin.listFolderTreeCalls).toBe(1); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); expect(queryByDataset(tab.containerEl, "folderRow", "notes")).toBeDefined(); expect(queryByDataset(tab.containerEl, "folderToggle", "notes")).toBeDefined(); expect(queryByDataset(tab.containerEl, "folderPathLabel", "notes")).toBeDefined(); await queryByDataset(tab.containerEl, "folderToggle", "notes").trigger("click"); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); const childCheckbox = queryByDataset(tab.containerEl, "folderPath", "notes/sub"); childCheckbox.checked = true; await childCheckbox.trigger("change"); expect(plugin.settings.includeFolders).toContain("notes/sub"); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); }); it("toggling file deck mode refreshes only the deck card", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); const initialEmptyCount = getEmptyCallCount(tab.containerEl); await queryByDataset(tab.containerEl, "settingsCardToggle", "deck").trigger("click"); const fileDeckSetting = findSetting(tab.containerEl, "开启文件级自定义牌组"); await getToggle(fileDeckSetting).triggerChange(true); expect(plugin.settings.fileDeckEnabled).toBe(true); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); }); it("renders the deck card with regrouped sections and normalized Chinese copy", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); expect(queryByDataset(tab.containerEl, "settingsCardToggle", "deck").textContent).toContain("卡片牌组设置"); expect(queryByDataset(tab.containerEl, "settingsCardToggle", "deck").textContent).not.toContain("Deck 与牌组规则"); await queryByDataset(tab.containerEl, "settingsCardToggle", "deck").trigger("click"); const deckBody = queryByDataset(tab.containerEl, "settingsCardBody", "deck"); const sectionTitles = queryAllByDataset(deckBody, "deckSectionTitle").map((element) => element.textContent || element.text); expect(sectionTitles).toEqual([ "1,推荐用「文件夹及文件名」作为「Anki牌组」", "2,可打开「文件级自定义牌组」,作为个性化定制", "3,默认牌组作为兜底", ]); const section1 = queryByDataset(deckBody, "deckSection", "folder-mapping"); const section2 = queryByDataset(deckBody, "deckSection", "file-deck"); const section3 = queryByDataset(deckBody, "deckSection", "default-deck"); expect(collectTexts(deckBody)).not.toContain("保留现有 deck 行为;切换文件级 deck 开关时只刷新这一张卡片。"); expect(collectTexts(deckBody)).toContain("若均不打开「文件夹及文件名」及「文件级」牌组,则以「默认牌组名称」作为 Anki 的牌组名。"); expect(collectTexts(deckBody)).toContain("最终牌组优先级:文件级自定义牌组 > 文件夹映射牌组 > 默认牌组。"); const exampleBlock = queryByDataset(section1, "deckExampleBlock", "true"); const exampleRows = queryAllByDataset(exampleBlock, "deckExampleRow"); expect(collectTexts(exampleBlock)).toContain("示例:"); expect(exampleRows).toHaveLength(2); expect(exampleBlock.style.marginLeft).toBe("32px"); expect(exampleRows[0]?.style.color).toBe("var(--text-muted)"); expect(exampleRows[0]?.style.fontSize).toBe("var(--font-ui-small)"); const fallbackHelper = queryByDataset(section3, "deckHelperText", "fallback"); const priorityFooter = queryByDataset(deckBody, "deckPriorityFooter", "true"); expect(fallbackHelper.style.marginLeft).toBe("32px"); expect(fallbackHelper.style.color).toBe("var(--text-muted)"); expect(fallbackHelper.style.fontSize).toBe("var(--font-ui-small)"); expect(priorityFooter.style.marginLeft).toBe("32px"); expect(priorityFooter.style.color).toBe("var(--text-muted)"); expect(priorityFooter.style.fontSize).toBe("var(--font-ui-small)"); expect(collectTexts(section3)).not.toContain("最终牌组优先级:文件级自定义牌组 > 文件夹映射牌组 > 默认牌组。"); expect(collectOwnedSettingNames(section1)).toEqual(["文件夹映射模式"]); expect(collectOwnedSettingNames(section2)).toEqual(["开启文件级自定义牌组"]); expect(collectOwnedSettingNames(section3)).toEqual(["默认牌组"]); const visibleDeckCopy = [ ...collectTexts(deckBody), ...collectOwnedSettingNames(section1), ...collectOwnedSettingNames(section2), ...collectOwnedSettingNames(section3), ...collectOwnedSettingDescs(section1), ...collectOwnedSettingDescs(section2), ...collectOwnedSettingDescs(section3), ].join("\n"); expect(visibleDeckCopy).not.toContain("文件级 deck"); expect(visibleDeckCopy).not.toContain("deck 模板"); expect(visibleDeckCopy).not.toContain("deck 声明"); expect(visibleDeckCopy).not.toContain("最终 deck 优先级"); expect(visibleDeckCopy).not.toContain("Deck 与牌组规则"); expect(findSetting(tab.containerEl, "默认牌组")).toBeDefined(); expect(findSetting(tab.containerEl, "文件夹映射模式")).toBeDefined(); expect(findSetting(tab.containerEl, "开启文件级自定义牌组")).toBeDefined(); }); it("keeps file-level custom deck controls in section 2 when enabled", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await queryByDataset(tab.containerEl, "settingsCardToggle", "deck").trigger("click"); const fileDeckSetting = findSetting(tab.containerEl, "开启文件级自定义牌组"); await getToggle(fileDeckSetting).triggerChange(true); const deckBody = queryByDataset(tab.containerEl, "settingsCardBody", "deck"); const section2 = queryByDataset(deckBody, "deckSection", "file-deck"); expect(collectOwnedSettingNames(section2)).toEqual([ "开启文件级自定义牌组", "牌组识别名", "默认牌组模板", "模板插入位置", "向当前文件插入牌组模板", ]); }); it("does not show the maintenance-only rebuild index command in the command guide", async () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); await queryByDataset(tab.containerEl, "settingsCardToggle", "commands").trigger("click"); const commandsBody = queryByDataset(tab.containerEl, "settingsCardBody", "commands"); const commandCopy = collectTexts(commandsBody).join("\n"); expect(commandCopy).toContain("同步当前文件到 Anki"); expect(commandCopy).toContain("同步全库到 Anki"); expect(commandCopy).toContain("清空当前文件已同步卡片"); expect(commandCopy).toContain("清理空牌组"); expect(commandCopy).not.toContain("重建卡片索引"); expect(commandCopy).not.toContain("只重建本地卡片索引,不创建或更新远端笔记。"); }); });