fix(sync): fix deck stats structure, empty deck identification, and test false positives

修复了 deck stats 返回结构读取错误、修复了空牌组候选识别错误、修复了对应测试的假阳性问题。
This commit is contained in:
Dusk 2026-04-18 22:53:17 +08:00
parent a5056dd71c
commit 1682ce5fa0
4 changed files with 199 additions and 14 deletions

66
docs/fix-gap-report.md Normal file
View file

@ -0,0 +1,66 @@
# Fix Gap Report
## 审查结论
本次真实故障链路与 review 结论一致。
### 1. 当前 gateway 仍按 deckName 直接读取 `rawStats[deckName]`
`AnkiConnectGateway.getDeckStats()` 当前通过 `extractDeckNoteCount(rawStats, deckName)` 取统计值,而该 helper 仍然使用:
1. `rawStats[deckName]`
2. 再读取 `total_in_deck`
这隐含假设:`getDeckStats` 的顶层 key 是 deck 名称。
### 2. 实际 AnkiConnect 返回结构不是 deckName-keyed
根据 `get_deck_stats` 文档示例,真实返回结构是:
1. 顶层 key 为 `deck_id` 字符串
2. deck 名称在 value 的 `name` 字段
3. 卡片总数在 value 的 `total_in_deck` 字段
因此当前实现对真实返回值会出现:
1. `rawStats[deckName]` 取不到
2. `noteCount` 变成 `undefined`
3. 上层候选筛选 `noteCount === 0` 全部失败
4. UI 提示“未发现可清理的空牌组”
### 3. 当前测试仍是假阳性
`AnkiConnectGateway.test.ts` 里的 mock 还在用错误结构:
1. 顶层 key 直接写 deckName
2. value 里没有 `name`
3. 正好迎合了错误实现
所以测试绿,不代表真实环境可用。
### 4. Use case 当前安全语义本身没有问题
`CleanupEmptyDecksUseCase` 现在已经保持了 fail-safe 语义:
1. 只有 `noteCount === 0` 才进入候选
2. `undefined` 不会被当成 empty
因此这次真正需要修的是:
1. gateway 对真实返回结构的读取方式
2. gateway tests 的假数据结构
3. 覆盖真实 payload 形状的回归测试
## 修复范围
本次只修:
1. `getDeckStats` 的真实 payload 读取
2. 空牌组候选识别的真实返回链路
3. 对应测试假阳性
不扩展到:
1. 空牌组清理 UI
2. 其他 deck API
3. 普通 sync / rebuild 逻辑

View file

@ -70,6 +70,19 @@ describe("CleanupEmptyDecksUseCase", () => {
expect(candidates).toEqual(["Empty"]);
});
it("includes only decks explicitly confirmed with zero notes", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.listedDeckNames = ["Empty A", "Busy", "Unknown", "Empty B"];
ankiGateway.deckStatsByName.set("Empty A", { deckName: "Empty A", noteCount: 0 });
ankiGateway.deckStatsByName.set("Busy", { deckName: "Busy", noteCount: 2 });
ankiGateway.deckStatsByName.set("Empty B", { deckName: "Empty B", noteCount: 0 });
const useCase = new CleanupEmptyDecksUseCase(ankiGateway);
const candidates = await useCase.listCandidates();
expect(candidates).toEqual(["Empty A", "Empty B"]);
});
it("returns zero candidates when Anki returns no stats for the listed decks", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.listedDeckNames = ["Deck A", "Deck B", "Deck C"];

View file

@ -112,8 +112,16 @@ describe("AnkiConnectGateway", () => {
json: {
error: null,
result: {
Empty: { total_in_deck: 0 },
Busy: { total_in_deck: 3 },
"1651445861967": {
deck_id: 1651445861967,
name: "Empty",
total_in_deck: 0,
},
"1651445861968": {
deck_id: 1651445861968,
name: "Busy",
total_in_deck: 3,
},
},
},
});
@ -139,7 +147,11 @@ describe("AnkiConnectGateway", () => {
json: {
error: null,
result: {
Empty: { total_in_deck: 0 },
"1651445861967": {
deck_id: 1651445861967,
name: "Empty",
total_in_deck: 0,
},
},
},
});
@ -175,9 +187,20 @@ describe("AnkiConnectGateway", () => {
json: {
error: null,
result: {
Broken: {},
WrongType: { total_in_deck: "0" },
Empty: { total_in_deck: 0 },
"1651445861967": {
deck_id: 1651445861967,
name: "Broken",
},
"1651445861968": {
deck_id: 1651445861968,
name: "WrongType",
total_in_deck: "0",
},
"1651445861969": {
deck_id: 1651445861969,
name: "Empty",
total_in_deck: 0,
},
},
},
});
@ -192,6 +215,79 @@ describe("AnkiConnectGateway", () => {
]);
});
it("matches deck stats by the value.name field when top-level keys are deck ids", async () => {
requestUrlMock.mockResolvedValue({
json: {
error: null,
result: {
"1651445861967": {
deck_id: 1651445861967,
name: "Scoped::Empty",
total_in_deck: 0,
},
"1651445861968": {
deck_id: 1651445861968,
name: "Scoped::Busy",
total_in_deck: 7,
},
},
},
});
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
const stats = await gateway.getDeckStats(["Scoped::Empty", "Scoped::Busy"]);
expect(stats).toEqual([
{ deckName: "Scoped::Empty", noteCount: 0 },
{ deckName: "Scoped::Busy", noteCount: 7 },
]);
});
it("treats partial deck-id keyed responses as unknown for unmatched deck names", async () => {
requestUrlMock.mockResolvedValue({
json: {
error: null,
result: {
"1651445861967": {
deck_id: 1651445861967,
name: "Scoped::Empty",
total_in_deck: 0,
},
},
},
});
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
const stats = await gateway.getDeckStats(["Scoped::Empty", "Scoped::Missing", "Scoped::Busy"]);
expect(stats).toEqual([
{ deckName: "Scoped::Empty", noteCount: 0 },
{ deckName: "Scoped::Missing", noteCount: undefined },
{ deckName: "Scoped::Busy", noteCount: undefined },
]);
});
it("treats deck-id keyed stats without a matching name as unknown", async () => {
requestUrlMock.mockResolvedValue({
json: {
error: null,
result: {
"1651445861967": {
deck_id: 1651445861967,
total_in_deck: 0,
},
},
},
});
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
const stats = await gateway.getDeckStats(["Scoped::Empty"]);
expect(stats).toEqual([
{ deckName: "Scoped::Empty", noteCount: undefined },
]);
});
it("batches add note calls through AnkiConnect multi", async () => {
requestUrlMock.mockResolvedValue({
json: {

View file

@ -16,6 +16,8 @@ interface NoteInfo {
}
interface RawDeckStats {
deck_id?: number;
name?: string;
total_in_deck?: number;
}
@ -247,15 +249,23 @@ function extractDeckNoteCount(rawStats: unknown, deckName: string): number | und
return undefined;
}
const rawDeckStat = (rawStats as Record<string, unknown>)[deckName];
if (!rawDeckStat || typeof rawDeckStat !== "object") {
for (const rawDeckStat of Object.values(rawStats as Record<string, unknown>)) {
if (!rawDeckStat || typeof rawDeckStat !== "object") {
continue;
}
const deckStat = rawDeckStat as RawDeckStats;
if (deckStat.name !== deckName) {
continue;
}
const totalInDeck = deckStat.total_in_deck;
if (typeof totalInDeck === "number" && Number.isFinite(totalInDeck)) {
return totalInDeck;
}
return undefined;
}
const totalInDeck = (rawDeckStat as RawDeckStats).total_in_deck;
if (typeof totalInDeck !== "number" || !Number.isFinite(totalInDeck)) {
return undefined;
}
return totalInDeck;
return undefined;
}