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, getLanguageMock, } = vi.hoisted(() => { const hoistedGetLanguage = vi.fn(() => "zh"); class HoistedFakeElement { public readonly children: HoistedFakeElement[] = []; public readonly dataset: Record = {}; public readonly style: Record = {}; 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>>(); 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; } 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 = ""; private onClickHandler?: () => void | Promise; setButtonText(text: string): this { this.text = 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; 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; } } return { FakeButtonComponent: HoistedFakeButtonComponent, FakeDropdownComponent: HoistedFakeDropdownComponent, FakeElement: HoistedFakeElement, FakePluginSettingTab: HoistedFakePluginSettingTab, FakeSetting: HoistedFakeSetting, getLanguageMock: hoistedGetLanguage, }; }); vi.mock("obsidian", () => ({ getLanguage: getLanguageMock, PluginSettingTab: FakePluginSettingTab, Setting: FakeSetting, })); import { AnkiHeadingSyncSettingTab } from "./PluginSettingTab"; type FakeContainer = InstanceType["containerEl"]; type FakeSettingInstance = InstanceType; type FakeToggleInstance = { triggerChange(value: boolean): Promise }; 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 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; return ["Basic", "Custom Basic", "Cloze", "Custom Cloze", "Semantic QA", QA_GROUP_USER_MODEL_NAME]; } 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[]; isCloze: boolean }> { this.getNoteModelDetailsCalls += 1; return { fieldNames: this.getFieldNamesForModel(modelName), isCloze: modelName.includes("Cloze"), }; } 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 collectTexts(container: QueryRoot): string[] { return findElements(container, () => true) .map((element) => element.textContent || element.text) .filter((text): text is string => Boolean(text)); } function collectOptionValues(selectEl: FakeElementInstance): string[] { return selectEl.children .filter((element) => element.tag === "option") .map((element) => element.value); } function getEmptyCallCount(container: QueryRoot): number { return asFakeContainer(container).emptyCallCount; } async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); } describe("PluginSettingTab", () => { beforeEach(() => { getLanguageMock.mockReturnValue("zh"); vi.useRealTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("renders five cards with the required default expansion state", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); const cards = queryAllByDataset(tab.containerEl, "settingsCard"); expect(cards).toHaveLength(5); expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("block"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "commands").style.display).toBe("block"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "sync-content").style.display).toBe("none"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none"); expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none"); }); it("renders card 1 as three readable card type blocks", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); 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("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 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", })); expect(queryByDataset(tab.containerEl, "qaGroupTitleField", "qa-group").textContent).toBe("题目"); expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toContain("已识别 2 组"); expect(plugin.settings.noteFieldMappings[createNoteFieldMappingKey("qa-group", plugin.settings.cardTypeConfigs["qa-group"].noteType)]).toEqual(expect.objectContaining({ titleField: "题目", 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(); 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(); const clozeHeading = queryByDataset(tab.containerEl, "cardTypeHeading", "cloze"); clozeHeading.value = "4"; await clozeHeading.trigger("change"); expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(5); 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(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); 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(); 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 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", }, }, }); const cachedTab = new AnkiHeadingSyncSettingTab(cachedPlugin as never); cachedTab.display(); 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(0); }); 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(); 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.getNoteModelDetailsCalls).toBe(0); }); it("shows qa-group warnings, saves acceptance, and resets acceptance after field refresh changes warnings", async () => { const plugin = new FakePlugin(); plugin.fieldNamesByModelName[QA_GROUP_USER_MODEL_NAME] = ["题目", "问题01", "答案01", "问题02"]; 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, }, }, }); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); expect(queryByDataset(tab.containerEl, "qaGroupWarning", "qa-group").textContent).toContain("第 02 组缺少答案字段"); const acceptWarning = queryByDataset(tab.containerEl, "qaGroupWarningAccept", "qa-group"); acceptWarning.checked = true; await acceptWarning.trigger("change"); const mappingKey = createNoteFieldMappingKey("qa-group", QA_GROUP_USER_MODEL_NAME); expect(plugin.settings.noteFieldMappings[mappingKey]).toEqual(expect.objectContaining({ acceptedWarnings: expect.any(Array), })); plugin.fieldNamesByModelName[QA_GROUP_USER_MODEL_NAME] = ["题目", "问题01", "答案01", "问题02", "答案02", "问题03"]; await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); await flushPromises(); expect(plugin.settings.noteFieldMappings[mappingKey]).toEqual(expect.objectContaining({ warnings: expect.any(Array), acceptedWarnings: undefined, })); expect(queryByDataset(tab.containerEl, "qaGroupWarning", "qa-group").textContent).toContain("第 03 组缺少答案字段"); }); 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); 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); }); });