Skip to content

Commit 996334b

Browse files
committed
test(flashcards): 补黄金错误聚合与恢复包纯函数测试(8 用例)
1 parent 32d4529 commit 996334b

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* 黄金错误聚合与恢复包纯函数测试
3+
*
4+
* @ai-context: 覆盖 summarizeGoldenErrors(统计聚合)与 recoveryPriority/
5+
* selectRecoveryCards(恢复包选择)——flashcards 模块此前零测试(2026-08 审计
6+
* R6),先补纯函数层(store/组件层随后)。
7+
* @ai-context: Pure-function tests for golden-error aggregation and recovery-pack
8+
* selection; no storage/network involved.
9+
*/
10+
import { describe, it, expect } from 'vitest';
11+
import { summarizeGoldenErrors, type GoldenErrorRecord } from './goldenErrorQueries';
12+
import { recoveryPriority, selectRecoveryCards, RECOVERY_PACK_SIZE } from './recoveryPack';
13+
import type { Flashcard, FlashcardReview } from '@/types/models';
14+
15+
const DAY_MS = 24 * 60 * 60 * 1000;
16+
17+
function review(over: Partial<FlashcardReview> & { id: string; cardId: string; reviewedAt: Date }): FlashcardReview {
18+
return {
19+
deckId: 'd1',
20+
rating: 3,
21+
easeFactorBefore: 2.5,
22+
easeFactorAfter: 2.5,
23+
intervalBefore: 1,
24+
intervalAfter: 2,
25+
timeSpent: 5,
26+
...over,
27+
} as FlashcardReview;
28+
}
29+
30+
function record(over: Partial<GoldenErrorRecord> & { review: FlashcardReview }): GoldenErrorRecord {
31+
return {
32+
front: '问题',
33+
back: '答案',
34+
deckId: over.review.deckId, // 实现按 review.deckId 聚合,保持一致
35+
...over,
36+
};
37+
}
38+
39+
function card(over: Partial<Flashcard> & { id: string; dueDate: Date }): Flashcard {
40+
return {
41+
deckId: 'd1',
42+
front: '问题',
43+
back: '答案',
44+
type: 'basic',
45+
easeFactor: 2.5,
46+
interval: 10,
47+
repetitions: 3,
48+
lapses: 0,
49+
createdAt: new Date(0),
50+
updatedAt: new Date(0),
51+
order: 0,
52+
...over,
53+
};
54+
}
55+
56+
describe('summarizeGoldenErrors', () => {
57+
it('should aggregate total, byDeck and repeat offenders', () => {
58+
// Arrange:同卡两次黄金错误(重复)+ 另一卡一次
59+
const records = [
60+
record({ review: review({ id: 'r1', cardId: 'c1', deckId: 'd1', reviewedAt: new Date(1) }) }),
61+
record({ review: review({ id: 'r2', cardId: 'c1', deckId: 'd1', reviewedAt: new Date(2) }) }),
62+
record({ review: review({ id: 'r3', cardId: 'c2', deckId: 'd2', reviewedAt: new Date(3) }) }),
63+
];
64+
// Act
65+
const stats = summarizeGoldenErrors(records);
66+
// Assert
67+
expect(stats.total).toBe(3);
68+
expect(stats.byDeck).toEqual({ d1: 2, d2: 1 });
69+
expect(stats.repeatOffenders).toEqual({ c1: 2 }); // c2 只出现 1 次,非顽固
70+
});
71+
72+
it('should return empty stats for empty input', () => {
73+
// Arrange/Act
74+
const stats = summarizeGoldenErrors([]);
75+
// Assert
76+
expect(stats).toEqual({ total: 0, byDeck: {}, repeatOffenders: {} });
77+
});
78+
});
79+
80+
describe('recoveryPriority', () => {
81+
it('should favor longer overdue and higher familiarity', () => {
82+
// Arrange:逾期 5 天 vs 逾期 10 天(同 repetitions)
83+
const now = Date.now();
84+
const c5 = card({ id: 'c5', dueDate: new Date(now - 5 * DAY_MS) });
85+
const c10 = card({ id: 'c10', dueDate: new Date(now - 10 * DAY_MS) });
86+
// Act
87+
const p5 = recoveryPriority(c5, now);
88+
const p10 = recoveryPriority(c10, now);
89+
// Assert:逾期翻倍 → 优先级更高
90+
expect(p10).toBeGreaterThan(p5);
91+
});
92+
93+
it('should cap overdue at 30 days and familiarity at 10', () => {
94+
// Arrange:逾期 100 天 + repetitions 100(均超封顶)
95+
const now = Date.now();
96+
const c = card({
97+
id: 'c-extreme',
98+
dueDate: new Date(now - 100 * DAY_MS),
99+
repetitions: 100,
100+
});
101+
// Act
102+
const p = recoveryPriority(c, now);
103+
// Assert:30*2 + 10 = 70
104+
expect(p).toBe(70);
105+
});
106+
107+
it('should treat future dueDate as zero overdue', () => {
108+
// Arrange:未到期卡片
109+
const now = Date.now();
110+
const c = card({ id: 'c-future', dueDate: new Date(now + 5 * DAY_MS) });
111+
// Act
112+
const p = recoveryPriority(c, now);
113+
// Assert:逾期 0 + familiarity
114+
expect(p).toBe(c.repetitions);
115+
});
116+
});
117+
118+
describe('selectRecoveryCards', () => {
119+
it('should only pick due old cards (repetitions > 0)', () => {
120+
// Arrange
121+
const now = Date.now();
122+
const dueOld = card({ id: 'due-old', dueDate: new Date(now - 1) });
123+
const notDue = card({ id: 'not-due', dueDate: new Date(now + DAY_MS) });
124+
const newCard = card({ id: 'brand-new', dueDate: new Date(now - 1), repetitions: 0 });
125+
// Act
126+
const selected = selectRecoveryCards([dueOld, notDue, newCard], now, 10);
127+
// Assert:仅到期旧卡入选
128+
expect(selected.map((c) => c.id)).toEqual(['due-old']);
129+
});
130+
131+
it('should sort by priority descending and respect size limit', () => {
132+
// Arrange:3 张到期卡,逾期不同
133+
const now = Date.now();
134+
const cards = [
135+
card({ id: 'low', dueDate: new Date(now - 1 * DAY_MS) }),
136+
card({ id: 'high', dueDate: new Date(now - 20 * DAY_MS) }),
137+
card({ id: 'mid', dueDate: new Date(now - 5 * DAY_MS) }),
138+
];
139+
// Act:限 2 张
140+
const selected = selectRecoveryCards(cards, now, 2);
141+
// Assert:优先逾期最久的 2 张
142+
expect(selected.map((c) => c.id)).toEqual(['high', 'mid']);
143+
});
144+
145+
it('should default to RECOVERY_PACK_SIZE limit', () => {
146+
// Arrange:11 张全到期旧卡
147+
const now = Date.now();
148+
const cards = Array.from({ length: 11 }, (_, i) =>
149+
card({ id: `c${i}`, dueDate: new Date(now - (i + 1) * DAY_MS) }),
150+
);
151+
// Act
152+
const selected = selectRecoveryCards(cards, now);
153+
// Assert
154+
expect(selected.length).toBe(RECOVERY_PACK_SIZE);
155+
expect(selected.length).toBe(10);
156+
});
157+
});

0 commit comments

Comments
 (0)