From b6e59c8eec1f65f517e34a793068324cb5db4393 Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 25 Apr 2026 23:16:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=BB=98=E8=AE=A4=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=8D=A1=E7=89=87=E5=B9=B6=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=20cloze=20=E9=BB=98=E8=AE=A4=E8=AF=86=E5=88=AB=20/=20collapse?= =?UTF-8?q?=20settings=20cards=20and=20migrate=20default=20cloze=20recogni?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ettings-collapse-cloze-marker-decisions.md | 88 ++++++++++++++ ...ttings-collapse-cloze-marker-gap-report.md | 99 ++++++++++++++++ src/application/config/PluginSettings.test.ts | 71 +++++++++++- src/application/config/PluginSettings.ts | 45 +++++++- .../services/CardIndexingService.test.ts | 107 ++++++++++++++++-- .../settings/PluginSettingTab.test.ts | 65 ++++++++++- src/presentation/settings/PluginSettingTab.ts | 2 +- 7 files changed, 455 insertions(+), 22 deletions(-) create mode 100644 docs/settings-collapse-cloze-marker-decisions.md create mode 100644 docs/settings-collapse-cloze-marker-gap-report.md diff --git a/docs/settings-collapse-cloze-marker-decisions.md b/docs/settings-collapse-cloze-marker-decisions.md new file mode 100644 index 0000000..a992322 --- /dev/null +++ b/docs/settings-collapse-cloze-marker-decisions.md @@ -0,0 +1,88 @@ +# 设置页默认折叠与填空题标记修复决策 + +本文锁定本轮“设置页全部默认折叠 + cloze 默认识别切换到 `H4 + #anki-cloze`”的最终实现策略。 + +## 1. 默认折叠策略 + +- `expandedCardIds` 默认改为空集合。 +- 不改 `toggleCard()`。 +- 不改 `renderCard()`。 +- 不改局部刷新边界。 + +原因: + +- 现有卡片折叠/展开与局部刷新行为已经正确,本轮只需要把首次进入设置页时的默认态改成“全部折叠”。 + +## 2. cloze 新默认决策 + +- 新默认常量: + - `DEFAULT_CLOZE_HEADING_LEVEL = 4` + - `DEFAULT_CLOZE_MARKER = "#anki-cloze"` +- `createDefaultCardTypeConfigs()` 中 cloze 默认改为: + - `headingLevel: 4` + - `extraMarker: "#anki-cloze"` + +原因: + +- 这样可以与现有 `#anki-list` 风格对齐。 +- 同级 `H4` 下: + - 空标签留给普通问答 + - `#anki-list` 留给 QA Group + - `#anki-cloze` 留给 Cloze + +## 3. 旧默认迁移策略 + +- 仅当 cloze 识别配置仍等于旧默认时才迁移: + - `headingLevel: 5` + - `extraMarker: ""` +- 迁移后改为: + - `headingLevel: 4` + - `extraMarker: "#anki-cloze"` +- 如果用户已经自定义 cloze 的 heading level 或 extra marker,则保留用户配置。 + +原因: + +- 本轮目标是替换旧默认,而不是重写用户自定义识别规则。 + +## 4. legacy 快照的判定策略 + +- 对只有旧顶层字段、没有显式 cloze per-card recognition 配置的 legacy 快照: + - 如果 `clozeHeadingLevel` 仍是旧默认 `5` + - 则将其视为可迁移的旧默认,升级到 `4 + #anki-cloze` + +原因: + +- 旧结构没有 cloze marker 字段,无法精确区分“显式保留 H5”与“沿用旧默认 H5”。 +- 仓库现实下只能把 legacy `H5` 视为旧默认并升级。 + +## 5. 识别与校验策略 + +- 保留现有 `resolveHeadingConfig()`: + - 先 marker 匹配 + - 再空 marker 默认回退 +- 保留现有“同一个 heading 只能有一个 enabled 空 marker 默认行”的校验。 + +原因: + +- 当前识别算法已经足够支持同级 `H4` 下的 marker 分流。 +- 只要 cloze 改成 `#anki-cloze`,默认冲突自然消失。 + +## 6. 测试策略 + +- 设置页测试全部改为“默认折叠”前提。 +- 需要读取 card 1 内容或触发局部刷新时,先点击展开对应卡片。 +- 新增或更新设置模型测试,覆盖: + - cloze 新默认 + - 旧默认迁移 + - 用户自定义保留 +- 新增识别测试,覆盖: + - `H4 + 空标签 -> basic` + - `H4 + #anki-list -> qa-group` + - `H4 + #anki-cloze -> cloze` + +## 7. 不变边界 + +- 不改 Anki 字段映射。 +- 不改卡片渲染逻辑。 +- 不改 sticky 设置页结构。 +- 不改 sync 执行逻辑,除默认识别配置变化自然影响扫描结果外,不新增行为。 \ No newline at end of file diff --git a/docs/settings-collapse-cloze-marker-gap-report.md b/docs/settings-collapse-cloze-marker-gap-report.md new file mode 100644 index 0000000..3c1cc6e --- /dev/null +++ b/docs/settings-collapse-cloze-marker-gap-report.md @@ -0,0 +1,99 @@ +# 设置页默认折叠与填空题标记差距审计 + +本文基于当前仓库真实实现,定位本轮两项行为调整的实际接入点:设置页默认折叠,以及填空题默认识别从 `H5 + 空标签` 切换为 `H4 + #anki-cloze`。 + +## 1. 当前设置页仍有默认展开卡片 + +- 入口在 [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts)。 +- 当前 `expandedCardIds` 初始化仍是: + +```ts +new Set(["card-types", "commands"]) +``` + +结果是:首次进入设置页时,`卡片类型及基础设置` 和 `命令说明` 默认展开,和本轮“5 张卡片全部默认折叠”的目标不一致。 + +## 2. 当前点击展开/折叠与局部刷新边界已经正确 + +- `toggleCard()` 只改 `expandedCardIds`。 +- `renderCard()` 只重绘单卡 `bodyEl`。 +- 现有测试也覆盖了: + - 卡片展开/折叠不整页重建 + - card 1 刷新不整页重建 + - scope/deck 局部刷新 + +因此本轮只需要把默认展开集合改为空,并同步更新测试,不需要改卡片 shell 结构或刷新逻辑。 + +## 3. 当前 cloze 默认仍是旧规则 `H5 + 空标签` + +- 当前默认值在 [src/application/config/PluginSettings.ts](src/application/config/PluginSettings.ts) 中仍是: + - `DEFAULT_CLOZE_HEADING_LEVEL = 5` + - `createDefaultCardTypeConfigs().cloze.extraMarker = ""` + +这意味着当前默认识别规则仍是: + +1. 普通问答:`H4 + 空标签` +2. 多级列表问答:`H4 + #anki-list` +3. 填空题:`H5 + 空标签` + +与本轮要求的: + +1. 普通问答:`H4 + 空标签` +2. 多级列表问答:`H4 + #anki-list` +3. 填空题:`H4 + #anki-cloze` + +不一致。 + +## 4. 当前设置合并逻辑还没有旧默认迁移 + +- `normalizePluginSettings()` 通过 `mergeCardTypeConfigs()` 合并默认值、旧字段和 `cardTypeConfigs`。 +- 目前 `mergeCardTypeConfigs()` 只做: + - 回填默认值 + - sanitize + - 衍生 legacy 字段 +- 还没有“旧默认 cloze 配置自动迁移到新默认”的规则。 + +因此已有用户如果保存的是: + +- `headingLevel: 5` +- `extraMarker: ""` + +当前加载后仍会保持旧行为。 + +## 5. 当前识别优先级已经支持同级 marker 分流 + +- 真正识别入口在 [src/domain/manual-sync/services/CardIndexingService.ts](src/domain/manual-sync/services/CardIndexingService.ts)。 +- `resolveHeadingConfig()` 当前规则是: + - 先在同级 enabled 配置中找 marker 精确尾随匹配 + - 再退回到同级 enabled 的空 marker 默认行 +- 匹配顺序是: + - `qa-group` + - `semantic-qa` + - `cloze` + - `basic` + +这说明本轮无需重写识别算法,只要把 cloze 默认改成 `H4 + #anki-cloze`,同级分流就会自然成立。 + +## 6. 需要适配的测试切片 + +- `PluginSettingTab.test.ts` 当前大量测试默认依赖 `card-types` 已展开。 +- 一旦默认改成全部折叠,相关测试必须先点击展开对应卡片,再断言局部刷新内容。 +- `PluginSettings.test.ts` 当前默认值和 legacy snapshot 断言仍写着 cloze 的旧默认。 +- `CardIndexingService.test.ts` 当前已有一些显式 `clozeHeadingLevel: 5` 的兼容测试,这些应保留;但还缺少“默认 H4 + #anki-cloze”路由断言。 + +## 7. 结论 + +当前真实差距是: + +1. 设置页仍默认展开两张卡片。 +2. cloze 默认仍是 `H5 + 空标签`。 +3. 设置合并逻辑没有旧默认迁移。 +4. 识别算法本身已经支持同级 marker 优先,不需要重写。 +5. 测试需要适配“默认全部折叠”与“默认 cloze marker 已改”的新事实。 + +最小修复路径是: + +1. 把 `expandedCardIds` 默认改为空集合。 +2. 把 cloze 默认改成 `H4 + #anki-cloze`。 +3. 在 `mergeCardTypeConfigs()` 中补旧默认迁移,只覆盖旧默认识别配置,不覆盖用户自定义 cloze 识别配置。 +4. 更新设置页、设置模型、识别测试与局部刷新测试。 \ No newline at end of file diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index 9c817d1..551b2fa 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -96,8 +96,8 @@ describe("PluginSettings", () => { }, cloze: { enabled: true, - headingLevel: 5, - extraMarker: "", + headingLevel: 4, + extraMarker: "#anki-cloze", noteType: "", }, "semantic-qa": { @@ -174,6 +174,69 @@ describe("PluginSettings", () => { }); }); + it("migrates the old default cloze recognition to H4 + #anki-cloze", () => { + const settings = mergePluginSettings({ + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + headingLevel: 5, + extraMarker: "", + }, + }, + }); + + expect(settings.cardTypeConfigs.cloze).toEqual(expect.objectContaining({ + headingLevel: 4, + extraMarker: "#anki-cloze", + })); + }); + + it("migrates legacy cloze H5 snapshots when no per-card recognition override exists", () => { + const settings = mergePluginSettings({ + clozeHeadingLevel: 5, + clozeNoteType: "Legacy Cloze", + }); + + expect(settings.cardTypeConfigs.cloze).toEqual(expect.objectContaining({ + headingLevel: 4, + extraMarker: "#anki-cloze", + noteType: "Legacy Cloze", + })); + }); + + it("preserves user customized cloze recognition settings", () => { + const customizedHeading = mergePluginSettings({ + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + headingLevel: 6, + extraMarker: "", + }, + }, + }); + expect(customizedHeading.cardTypeConfigs.cloze).toEqual(expect.objectContaining({ + headingLevel: 6, + extraMarker: "", + })); + + const customizedMarker = mergePluginSettings({ + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + headingLevel: 4, + extraMarker: "#custom-cloze", + }, + }, + }); + expect(customizedMarker.cardTypeConfigs.cloze).toEqual(expect.objectContaining({ + headingLevel: 4, + extraMarker: "#custom-cloze", + })); + }); + it("rejects invalid cached Anki note type lists", () => { expectPluginUserError(() => { validatePluginSettings({ @@ -267,8 +330,8 @@ describe("PluginSettings", () => { }, cloze: { enabled: true, - headingLevel: 5, - extraMarker: "", + headingLevel: 4, + extraMarker: "#anki-cloze", noteType: "Legacy Cloze", }, "semantic-qa": { diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index d6dd4bf..6819f04 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -30,9 +30,12 @@ export type AnkiModelFieldCache = Record; export const DEFAULT_OBSIDIAN_BACKLINK_LABEL = "Open in Obsidian"; const DEFAULT_BASIC_HEADING_LEVEL = 4; -const DEFAULT_CLOZE_HEADING_LEVEL = 5; +const LEGACY_DEFAULT_CLOZE_HEADING_LEVEL = 5; +const DEFAULT_CLOZE_HEADING_LEVEL = 4; const DEFAULT_BASIC_NOTE_TYPE = ""; const DEFAULT_CLOZE_NOTE_TYPE = ""; +const LEGACY_DEFAULT_CLOZE_MARKER = ""; +const DEFAULT_CLOZE_MARKER = "#anki-cloze"; const DEFAULT_SEMANTIC_QA_NOTE_TYPE = "Semantic QA"; const DEFAULT_QA_GROUP_MARKER = "#anki-list"; const DEFAULT_SEMANTIC_QA_MARKER = "#anki-list-qa"; @@ -375,7 +378,7 @@ function createDefaultCardTypeConfigs(): CardTypeConfigs { cloze: { enabled: true, headingLevel: DEFAULT_CLOZE_HEADING_LEVEL, - extraMarker: "", + extraMarker: DEFAULT_CLOZE_MARKER, noteType: DEFAULT_CLOZE_NOTE_TYPE, }, "semantic-qa": { @@ -430,17 +433,53 @@ function mergeCardTypeConfigs( ? { ...fallbackConfig, ...rawConfig } : fallbackConfig; - nextConfigs[configId] = { + const normalizedConfig: CardTypeConfig = { enabled: typeof mergedConfig.enabled === "boolean" ? mergedConfig.enabled : fallbackConfig.enabled, headingLevel: sanitizeHeadingLevel(mergedConfig.headingLevel, fallbackConfig.headingLevel), extraMarker: sanitizeMarker(mergedConfig.extraMarker, fallbackConfig.extraMarker), noteType: sanitizeNoteType(mergedConfig.noteType, fallbackConfig.noteType), }; + + nextConfigs[configId] = configId === "cloze" + ? migrateLegacyDefaultClozeConfig(normalizedConfig, rawConfig, legacySettings) + : normalizedConfig; } return nextConfigs; } +function migrateLegacyDefaultClozeConfig( + config: CardTypeConfig, + rawConfig: Partial | null | undefined, + legacySettings: Partial, +): CardTypeConfig { + if (config.headingLevel === LEGACY_DEFAULT_CLOZE_HEADING_LEVEL && config.extraMarker === LEGACY_DEFAULT_CLOZE_MARKER) { + return { + ...config, + headingLevel: DEFAULT_CLOZE_HEADING_LEVEL, + extraMarker: DEFAULT_CLOZE_MARKER, + }; + } + + if (!hasExplicitClozeRecognitionConfig(rawConfig) && legacySettings.clozeHeadingLevel === LEGACY_DEFAULT_CLOZE_HEADING_LEVEL) { + return { + ...config, + headingLevel: DEFAULT_CLOZE_HEADING_LEVEL, + extraMarker: DEFAULT_CLOZE_MARKER, + }; + } + + return config; +} + +function hasExplicitClozeRecognitionConfig(rawConfig: Partial | null | undefined): boolean { + if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) { + return false; + } + + return "headingLevel" in rawConfig || "extraMarker" in rawConfig; +} + function deriveLegacySettings(cardTypeConfigs: CardTypeConfigs): Pick { return { qaHeadingLevel: cardTypeConfigs.basic.headingLevel, diff --git a/src/domain/manual-sync/services/CardIndexingService.test.ts b/src/domain/manual-sync/services/CardIndexingService.test.ts index 41405d0..14c0e54 100644 --- a/src/domain/manual-sync/services/CardIndexingService.test.ts +++ b/src/domain/manual-sync/services/CardIndexingService.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings"; import { buildGroupSrc } from "@/domain/manual-sync/entities/IndexedGroupCardBlock"; import type { CardState, GroupBlockState } from "@/domain/manual-sync/entities/PluginState"; import { hashString } from "@/domain/shared/hash"; @@ -190,8 +191,14 @@ describe("CardIndexingService", () => { content: ["#### Prompt", "Answer", "", "", "Remarks"].join("\n"), }, { - qaHeadingLevel: 4, - clozeHeadingLevel: 5, + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + headingLevel: 5, + extraMarker: "", + }, + }, cardAnswerCutoffMode: "double-blank-lines", fileStamp: "1:1", knownCards: [], @@ -217,8 +224,14 @@ describe("CardIndexingService", () => { content: ["#### Prompt", "Answer", "", "", "Remarks", ""].join("\n"), }, { - qaHeadingLevel: 4, - clozeHeadingLevel: 5, + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + headingLevel: 5, + extraMarker: "", + }, + }, cardAnswerCutoffMode: "double-blank-lines", fileStamp: "1:1", knownCards: [], @@ -245,8 +258,14 @@ describe("CardIndexingService", () => { content: ["##### Cloze", "{{c1::Answer}}", "", "", "Remarks"].join("\n"), }, { - qaHeadingLevel: 4, - clozeHeadingLevel: 5, + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + headingLevel: 5, + extraMarker: "", + }, + }, cardAnswerCutoffMode: "double-blank-lines", fileStamp: "1:1", knownCards: [], @@ -261,6 +280,72 @@ describe("CardIndexingService", () => { }); }); + it("routes H4 headings by marker priority with the default card type configs", () => { + const service = new CardIndexingService(); + const indexedFile = service.index( + { + path: "notes/example.md", + basename: "example", + content: [ + "#### Basic", + "Answer", + "", + "#### Cloze #anki-cloze", + "{{c1::Answer}}", + "", + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + ].join("\n"), + }, + { + cardTypeConfigs: DEFAULT_SETTINGS.cardTypeConfigs, + fileStamp: "1:1", + knownCards: [], + pendingWriteBack: [], + }, + ); + + expect(indexedFile.cards.map((card) => card.cardType)).toEqual(["basic", "cloze"]); + expect(indexedFile.groupBlocks?.[0]).toMatchObject({ + headingText: "Concepts #anki-list", + headingLevel: 4, + }); + }); + + it("does not produce cloze cards when the cloze config is disabled", () => { + const service = new CardIndexingService(); + const indexedFile = service.index( + { + path: "notes/example.md", + basename: "example", + content: [ + "#### Cloze #anki-cloze", + "{{c1::Answer}}", + ].join("\n"), + }, + { + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + basic: { + ...DEFAULT_SETTINGS.cardTypeConfigs.basic, + enabled: false, + }, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + enabled: false, + }, + }, + fileStamp: "1:1", + knownCards: [], + pendingWriteBack: [], + }, + ); + + expect(indexedFile.cards).toHaveLength(0); + expect(indexedFile.groupBlocks ?? []).toHaveLength(0); + }); + it("splits a tagged QA heading into semantic QA child cards with distinct titles and backlink anchors", () => { const service = new CardIndexingService(); const indexedFile = service.index( @@ -488,8 +573,14 @@ describe("CardIndexingService", () => { tags: ["📖::一人公司", "3地区"], }, { - qaHeadingLevel: 4, - clozeHeadingLevel: 5, + cardTypeConfigs: { + ...DEFAULT_SETTINGS.cardTypeConfigs, + cloze: { + ...DEFAULT_SETTINGS.cardTypeConfigs.cloze, + headingLevel: 5, + extraMarker: "", + }, + }, semanticQaMarker: "#anki-list-qa", syncObsidianTagsToAnki: true, fileStamp: "1:1", diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index f21014f..aa74420 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -573,6 +573,14 @@ async function flushPromises(): Promise { 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; @@ -593,7 +601,7 @@ describe("PluginSettingTab", () => { Reflect.set(globalThis, "ResizeObserver", originalResizeObserver); }); - it("renders five cards with the required default expansion state", () => { + it("renders five cards collapsed by default", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); @@ -602,11 +610,34 @@ describe("PluginSettingTab", () => { expect(queryByDataset(tab.containerEl, "settingsPageHeader", "true")).toBeDefined(); 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", "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"); + + for (const cardId of ["card-types", "sync-content", "scope", "deck", "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", () => { @@ -673,11 +704,12 @@ describe("PluginSettingTab", () => { expect(resizeObserver?.disconnected).toBe(true); }); - it("renders card 1 as three readable card type blocks", () => { + 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); @@ -698,6 +730,7 @@ describe("PluginSettingTab", () => { const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); + await expandCard(tab, "card-types"); await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); await flushPromises(); @@ -755,6 +788,7 @@ describe("PluginSettingTab", () => { 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"; @@ -779,12 +813,22 @@ describe("PluginSettingTab", () => { 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"); - expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(5); + 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); }); @@ -809,6 +853,7 @@ describe("PluginSettingTab", () => { 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"); @@ -826,6 +871,7 @@ describe("PluginSettingTab", () => { const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); + await expandCard(tab, "card-types"); await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); await flushPromises(); @@ -876,6 +922,7 @@ describe("PluginSettingTab", () => { const cachedTab = new AnkiHeadingSyncSettingTab(cachedPlugin as never); cachedTab.display(); + await expandCard(cachedTab, "card-types"); await flushPromises(); const basicNoteTypeSelect = queryByDataset(cachedTab.containerEl, "cardTypeNoteType", "basic"); @@ -890,11 +937,12 @@ describe("PluginSettingTab", () => { 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", () => { + 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); @@ -924,6 +972,7 @@ describe("PluginSettingTab", () => { const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); + await expandCard(tab, "card-types"); await flushPromises(); expect(plugin.listNoteModelsCalls).toBe(1); @@ -955,6 +1004,7 @@ describe("PluginSettingTab", () => { const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); + await expandCard(tab, "card-types"); await flushPromises(); expect(plugin.listNoteModelsCalls).toBe(1); @@ -986,6 +1036,7 @@ describe("PluginSettingTab", () => { 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); @@ -1024,6 +1075,7 @@ describe("PluginSettingTab", () => { const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); + await expandCard(tab, "card-types"); const basicNoteTypeSelect = queryByDataset(tab.containerEl, "cardTypeNoteType", "basic"); basicNoteTypeSelect.value = "Basic"; @@ -1060,6 +1112,7 @@ describe("PluginSettingTab", () => { const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); + await expandCard(tab, "card-types"); expect(queryByDataset(tab.containerEl, "qaGroupWarning", "qa-group").textContent).toContain("第 02 组缺少答案字段"); const acceptWarning = queryByDataset(tab.containerEl, "qaGroupWarningAccept", "qa-group"); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index f8bcae1..6e76223 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -67,7 +67,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { private readonly debouncedTextSaves = new Map>(); private readonly textDraftValues = new Map(); private readonly cardShells = new Map(); - private readonly expandedCardIds = new Set(["card-types", "commands"]); + private readonly expandedCardIds = new Set(); private readonly expandedFolderPaths = new Set(); private folderTree: FolderTreeNode[] = [];