feat(phase3): learning helpers — summary, glossary, flashcards (SM-2), quizzes
Deterministic, on-device, no model: - src/lib/learn pure modules (tokenize, summary [TextRank-ish], glossary [definition-pattern + frequency], flashcards [cloze/Q-A], srs [SM-2], quiz [MCQ with distractors]) — 37 unit tests. - Flashcard persistence: Dexie v4 + native v4 `flashcards` table; create/list/ listDue/updateSrs/delete/counts; cascades (transcript delete, course->Unsorted). - UI: transcript "Study aids" (generate summary+glossary, click-to-seek; create flashcards), Study screen (SM-2 review + Anki CSV export), per-lecture Quiz, library Study link with due-count badge. 215 tests green, 0 tsc errors, web export builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { initialSrs, review } from './srs';
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
describe('initialSrs', () => {
|
||||
it('creates a neutral, immediately-due card', () => {
|
||||
const s = initialSrs(NOW);
|
||||
expect(s).toEqual({
|
||||
ease: 2.5,
|
||||
intervalDays: 0,
|
||||
reps: 0,
|
||||
lapses: 0,
|
||||
due: NOW,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('review', () => {
|
||||
it('does not mutate the input state', () => {
|
||||
const s = initialSrs(NOW);
|
||||
const frozen = { ...s };
|
||||
review(s, 2, NOW);
|
||||
expect(s).toEqual(frozen);
|
||||
});
|
||||
|
||||
it('produces monotonically increasing intervals on repeated "good"', () => {
|
||||
let s = initialSrs(NOW);
|
||||
const intervals: number[] = [];
|
||||
let t = NOW;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
s = review(s, 2, t);
|
||||
intervals.push(s.intervalDays);
|
||||
t += s.intervalDays * MS_PER_DAY;
|
||||
}
|
||||
// 1 day, 6 days, then *ease each time -> strictly increasing.
|
||||
for (let i = 1; i < intervals.length; i++) {
|
||||
expect(intervals[i]!).toBeGreaterThan(intervals[i - 1]!);
|
||||
}
|
||||
expect(intervals[0]).toBe(1);
|
||||
expect(intervals[1]).toBe(6);
|
||||
});
|
||||
|
||||
it('increments reps on passing grades and resets on "again"', () => {
|
||||
let s = initialSrs(NOW);
|
||||
s = review(s, 2, NOW); // good
|
||||
s = review(s, 2, NOW); // good
|
||||
expect(s.reps).toBe(2);
|
||||
s = review(s, 0, NOW); // again
|
||||
expect(s.reps).toBe(0);
|
||||
expect(s.lapses).toBe(1);
|
||||
});
|
||||
|
||||
it('floors ease at 1.3 even after many "again" reviews', () => {
|
||||
let s = initialSrs(NOW);
|
||||
for (let i = 0; i < 20; i++) s = review(s, 0, NOW);
|
||||
expect(s.ease).toBe(1.3);
|
||||
});
|
||||
|
||||
it('lowers ease on hard, raises it on easy', () => {
|
||||
const good = review(initialSrs(NOW), 2, NOW);
|
||||
expect(good.ease).toBe(2.5);
|
||||
const hard = review(initialSrs(NOW), 1, NOW);
|
||||
expect(hard.ease).toBeCloseTo(2.35, 5);
|
||||
const easy = review(initialSrs(NOW), 3, NOW);
|
||||
expect(easy.ease).toBeCloseTo(2.65, 5);
|
||||
});
|
||||
|
||||
it('computes due = now + intervalDays * 86400000 and sets lastReviewed', () => {
|
||||
const s = review(initialSrs(NOW), 2, NOW);
|
||||
expect(s.intervalDays).toBe(1);
|
||||
expect(s.due).toBe(NOW + 1 * MS_PER_DAY);
|
||||
expect(s.lastReviewed).toBe(NOW);
|
||||
});
|
||||
|
||||
it('gives "easy" a longer interval than "good" at the same step', () => {
|
||||
const good = review(initialSrs(NOW), 2, NOW);
|
||||
const easy = review(initialSrs(NOW), 3, NOW);
|
||||
expect(easy.intervalDays).toBeGreaterThan(good.intervalDays);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user