mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
feat: 默认折叠设置卡片并迁移 cloze 默认识别 / collapse settings cards and migrate default cloze recognition
This commit is contained in:
parent
0955ba952d
commit
b6e59c8eec
7 changed files with 455 additions and 22 deletions
88
docs/settings-collapse-cloze-marker-decisions.md
Normal file
88
docs/settings-collapse-cloze-marker-decisions.md
Normal file
|
|
@ -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 执行逻辑,除默认识别配置变化自然影响扫描结果外,不新增行为。
|
||||
99
docs/settings-collapse-cloze-marker-gap-report.md
Normal file
99
docs/settings-collapse-cloze-marker-gap-report.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# 设置页默认折叠与填空题标记差距审计
|
||||
|
||||
本文基于当前仓库真实实现,定位本轮两项行为调整的实际接入点:设置页默认折叠,以及填空题默认识别从 `H5 + 空标签` 切换为 `H4 + #anki-cloze`。
|
||||
|
||||
## 1. 当前设置页仍有默认展开卡片
|
||||
|
||||
- 入口在 [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts)。
|
||||
- 当前 `expandedCardIds` 初始化仍是:
|
||||
|
||||
```ts
|
||||
new Set<SettingsCardId>(["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. 更新设置页、设置模型、识别测试与局部刷新测试。
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -30,9 +30,12 @@ export type AnkiModelFieldCache = Record<string, AnkiModelFieldCacheEntry>;
|
|||
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<CardTypeConfig> | null | undefined,
|
||||
legacySettings: Partial<PluginSettings>,
|
||||
): 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<CardTypeConfig> | 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<PluginSettings, "qaHeadingLevel" | "clozeHeadingLevel" | "qaGroupMarker" | "qaNoteType" | "clozeNoteType" | "semanticQaMarker" | "semanticQaNoteType"> {
|
||||
return {
|
||||
qaHeadingLevel: cardTypeConfigs.basic.headingLevel,
|
||||
|
|
|
|||
|
|
@ -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", "<!--ID: 42-->"].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",
|
||||
|
|
|
|||
|
|
@ -573,6 +573,14 @@ async function flushPromises(): Promise<void> {
|
|||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async function expandCard(tab: AnkiHeadingSyncSettingTab, cardId: string): Promise<void> {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
private readonly debouncedTextSaves = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private readonly textDraftValues = new Map<string, string>();
|
||||
private readonly cardShells = new Map<SettingsCardId, SettingsCardShell>();
|
||||
private readonly expandedCardIds = new Set<SettingsCardId>(["card-types", "commands"]);
|
||||
private readonly expandedCardIds = new Set<SettingsCardId>();
|
||||
private readonly expandedFolderPaths = new Set<string>();
|
||||
|
||||
private folderTree: FolderTreeNode[] = [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue