Skip to content

Commit 95cad89

Browse files
committed
test(flashcards): 补 sessionMath 纯函数测试(13 用例),覆盖率回达标 60.35/51.09/53.64
1 parent 0dcc26a commit 95cad89

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* 学习会话纯函数测试
3+
*
4+
* @ai-context: 覆盖 sessionMath(洗牌/黄金错误压缩/去重/会话组装)——
5+
* 从 useStudySessionStore 拆出的纯函数层(R3 拆分后新增,补足覆盖率)。
6+
* 纯函数测试,无 storage/network 依赖(countReviewsToday 依赖 storage,
7+
* 本文件不测该异步查询)。
8+
* @ai-context: Pure-function tests for session-math helpers extracted from
9+
* useStudySessionStore; no storage or network involved.
10+
*/
11+
import { describe, it, expect, vi, beforeEach } from 'vitest';
12+
import { shuffle, compressForGoldenError, dedupeCards, buildSessionCards } from './sessionMath';
13+
import * as schedulingFactory from '@/lib/schedulingFactory';
14+
import type { Flashcard } from '@/types/models';
15+
16+
const DAY_MS = 24 * 60 * 60 * 1000;
17+
18+
function card(over: Partial<Flashcard> & { id: string; dueDate: Date }): Flashcard {
19+
return {
20+
deckId: 'd1',
21+
front: '问题',
22+
back: '答案',
23+
type: 'basic',
24+
easeFactor: 2.5,
25+
interval: 10,
26+
repetitions: 3,
27+
lapses: 0,
28+
createdAt: new Date(0),
29+
updatedAt: new Date(0),
30+
order: 0,
31+
...over,
32+
};
33+
}
34+
35+
describe('shuffle', () => {
36+
it('should keep same elements (permutation)', () => {
37+
// Arrange
38+
const arr = [1, 2, 3, 4, 5];
39+
// Act
40+
const result = shuffle(arr);
41+
// Assert:元素集合不变、原数组不被修改
42+
expect([...result].sort()).toEqual([1, 2, 3, 4, 5]);
43+
expect(arr).toEqual([1, 2, 3, 4, 5]);
44+
});
45+
46+
it('should return empty for empty input', () => {
47+
// Arrange/Act/Assert
48+
expect(shuffle([])).toEqual([]);
49+
});
50+
51+
it('should return copy (not same reference)', () => {
52+
// Arrange
53+
const arr = [1, 2, 3];
54+
// Act
55+
const result = shuffle(arr);
56+
// Assert
57+
expect(result).not.toBe(arr);
58+
});
59+
});
60+
61+
describe('compressForGoldenError', () => {
62+
const now = new Date('2026-08-15T12:00:00Z');
63+
64+
it('should cap interval to 1 day', () => {
65+
// Arrange:调度给出 30 天间隔
66+
const due = new Date(now.getTime() + 30 * DAY_MS);
67+
// Act
68+
const r = compressForGoldenError(due, 30, now);
69+
// Assert:interval 封顶 1
70+
expect(r.interval).toBe(1);
71+
expect(r.dueDate.getTime()).toBeLessThanOrEqual(now.getTime() + DAY_MS);
72+
});
73+
74+
it('should keep short interval unchanged', () => {
75+
// Arrange:间隔已 ≤1 天
76+
const due = new Date(now.getTime() + 12 * 60 * 60 * 1000);
77+
// Act
78+
const r = compressForGoldenError(due, 0, now);
79+
// Assert:原样保留(dueDate 未超过 maxDue,interval 取 min)
80+
expect(r.interval).toBe(0);
81+
expect(r.dueDate).toEqual(due);
82+
});
83+
});
84+
85+
describe('dedupeCards', () => {
86+
it('should remove duplicate ids', () => {
87+
// Arrange
88+
const a = card({ id: 'a', dueDate: new Date(0) });
89+
const b = card({ id: 'b', dueDate: new Date(0) });
90+
// Act
91+
const r = dedupeCards([a, b, a]);
92+
// Assert
93+
expect(r.map((c) => c.id)).toEqual(['a', 'b']);
94+
});
95+
96+
it('should respect inherited ids (due list exclusion)', () => {
97+
// Arrange:inheritIds 含 a(已在到期列表)
98+
const a = card({ id: 'a', dueDate: new Date(0) });
99+
const b = card({ id: 'b', dueDate: new Date(0) });
100+
// Act
101+
const r = dedupeCards([a, b], new Set(['a']));
102+
// Assert:a 被继承集合排除
103+
expect(r.map((c) => c.id)).toEqual(['b']);
104+
});
105+
});
106+
107+
describe('buildSessionCards', () => {
108+
const now = new Date('2026-08-15T12:00:00Z');
109+
const dueOld = (id: string) => card({ id, dueDate: new Date(now.getTime() - DAY_MS) });
110+
const brandNew = (id: string) => card({ id, dueDate: now, repetitions: 0 });
111+
112+
beforeEach(() => {
113+
// 默认限额(与 schedulingFactory 默认一致):每日复习 80、新卡 20、会话 20
114+
vi.spyOn(schedulingFactory, 'getMaxReviewsPerDay').mockReturnValue(80);
115+
vi.spyOn(schedulingFactory, 'getMaxNewCardsPerDay').mockReturnValue(20);
116+
vi.spyOn(schedulingFactory, 'getMaxSessionCards').mockReturnValue(20);
117+
});
118+
119+
it('should return empty when daily review quota exhausted', () => {
120+
// Arrange:今日已复习 80(额度用尽)
121+
const cards = [dueOld('a')];
122+
// Act
123+
const r = buildSessionCards({ allCards: cards, reviewsToday: 80, newCardsStartedToday: 0, now });
124+
// Assert
125+
expect(r).toEqual([]);
126+
});
127+
128+
it('should use only due cards when enough (>=10)', () => {
129+
// Arrange:12 张到期卡 + 1 张新卡
130+
const due = Array.from({ length: 12 }, (_, i) => dueOld(`d${i}`));
131+
const n = brandNew('n1');
132+
// Act
133+
const r = buildSessionCards({ allCards: [...due, n], reviewsToday: 0, newCardsStartedToday: 0, now });
134+
// Assert:只用到期卡(不含新卡)
135+
expect(r.length).toBe(12);
136+
expect(r.some((c) => c.repetitions === 0)).toBe(false);
137+
});
138+
139+
it('should top up new cards when due cards insufficient', () => {
140+
// Arrange:3 张到期卡 + 5 张新卡(不足 10 阈值 → 补充新卡)
141+
const due = [dueOld('d1'), dueOld('d2'), dueOld('d3')];
142+
const news = Array.from({ length: 5 }, (_, i) => brandNew(`n${i}`));
143+
// Act
144+
const r = buildSessionCards({ allCards: [...due, ...news], reviewsToday: 0, newCardsStartedToday: 0, now });
145+
// Assert:到期卡 + 补充新卡(总量 min(20, 80)=8)
146+
expect(r.length).toBe(8);
147+
});
148+
149+
it('should respect limit (F3 mini review)', () => {
150+
// Arrange:12 张到期卡,limit 5
151+
const due = Array.from({ length: 12 }, (_, i) => dueOld(`d${i}`));
152+
// Act
153+
const r = buildSessionCards({ allCards: due, reviewsToday: 0, newCardsStartedToday: 0, now, limit: 5 });
154+
// Assert
155+
expect(r.length).toBe(5);
156+
});
157+
158+
it('should respect remaining new-card quota', () => {
159+
// Arrange:3 张到期卡 + 5 张新卡,但今日新卡额度只剩 2
160+
const due = [dueOld('d1'), dueOld('d2'), dueOld('d3')];
161+
const news = Array.from({ length: 5 }, (_, i) => brandNew(`n${i}`));
162+
// Act
163+
const r = buildSessionCards({ allCards: [...due, ...news], reviewsToday: 0, newCardsStartedToday: 18, now });
164+
// Assert:3 到期 + 2 新卡(额度限制)
165+
expect(r.length).toBe(5);
166+
expect(r.filter((c) => c.repetitions === 0).length).toBe(2);
167+
});
168+
169+
it('should exclude not-yet-due cards', () => {
170+
// Arrange:1 张到期 + 1 张未到期 + 1 张新卡
171+
const future = card({ id: 'future', dueDate: new Date(now.getTime() + DAY_MS) });
172+
// Act
173+
const r = buildSessionCards({ allCards: [dueOld('due'), future, brandNew('new')], reviewsToday: 0, newCardsStartedToday: 0, now });
174+
// Assert:未到期卡不入选
175+
expect(r.some((c) => c.id === 'future')).toBe(false);
176+
});
177+
});

0 commit comments

Comments
 (0)