import { describe, it, expect } from "vitest" import { INITIAL_PROGRESS, calculateCorrectAnswer, calculateIncorrectAnswer, Difficulty, selectCardsForSession, generateWrongAnswers, shuffleOptions, type CardProgress, type SelectableCard, type HanziOption, } from "./sm2" /** * Unit tests for SM-2 Algorithm * * Tests the spaced repetition algorithm implementation * following the specification exactly. */ describe("SM-2 Algorithm", () => { describe("INITIAL_PROGRESS", () => { it("should have correct initial values", () => { expect(INITIAL_PROGRESS.easeFactor).toBe(2.5) expect(INITIAL_PROGRESS.interval).toBe(1) expect(INITIAL_PROGRESS.consecutiveCorrect).toBe(0) expect(INITIAL_PROGRESS.incorrectCount).toBe(0) }) }) describe("calculateCorrectAnswer", () => { it("should set interval to 1 for first correct answer", () => { const progress: CardProgress = { ...INITIAL_PROGRESS, } const result = calculateCorrectAnswer(progress, new Date("2025-01-01")) expect(result.interval).toBe(1) expect(result.consecutiveCorrect).toBe(1) expect(result.easeFactor).toBe(2.6) // 2.5 + 0.1 expect(result.nextReviewDate).toEqual(new Date("2025-01-02")) }) it("should set interval to 6 for second correct answer", () => { const progress: CardProgress = { easeFactor: 2.6, interval: 1, consecutiveCorrect: 1, incorrectCount: 0, nextReviewDate: new Date("2025-01-02"), } const result = calculateCorrectAnswer(progress, new Date("2025-01-02")) expect(result.interval).toBe(6) expect(result.consecutiveCorrect).toBe(2) expect(result.easeFactor).toBe(2.7) // 2.6 + 0.1 expect(result.nextReviewDate).toEqual(new Date("2025-01-08")) }) it("should multiply interval by easeFactor for third+ correct answer", () => { const progress: CardProgress = { easeFactor: 2.7, interval: 6, consecutiveCorrect: 2, incorrectCount: 0, nextReviewDate: new Date("2025-01-08"), } const result = calculateCorrectAnswer(progress, new Date("2025-01-08")) // 6 * 2.7 = 16.2, rounded = 16 expect(result.interval).toBe(16) expect(result.consecutiveCorrect).toBe(3) expect(result.easeFactor).toBeCloseTo(2.8) // 2.7 + 0.1 expect(result.nextReviewDate).toEqual(new Date("2025-01-24")) }) it("should continue increasing ease factor with each correct answer", () => { const progress: CardProgress = { easeFactor: 3.0, interval: 50, consecutiveCorrect: 5, incorrectCount: 2, nextReviewDate: new Date("2025-02-20"), } const result = calculateCorrectAnswer(progress, new Date("2025-02-20")) // 50 * 3.0 = 150 expect(result.interval).toBe(150) expect(result.consecutiveCorrect).toBe(6) expect(result.easeFactor).toBe(3.1) // 3.0 + 0.1 expect(result.incorrectCount).toBe(2) // Should not change }) it("should use current date by default", () => { const progress: CardProgress = { ...INITIAL_PROGRESS, } const before = new Date() const result = calculateCorrectAnswer(progress) const after = new Date() // Next review should be approximately 1 day from now const expectedMin = new Date(before) expectedMin.setDate(expectedMin.getDate() + 1) const expectedMax = new Date(after) expectedMax.setDate(expectedMax.getDate() + 1) expect(result.nextReviewDate.getTime()).toBeGreaterThanOrEqual( expectedMin.getTime() ) expect(result.nextReviewDate.getTime()).toBeLessThanOrEqual( expectedMax.getTime() ) }) it("should handle large intervals correctly", () => { const progress: CardProgress = { easeFactor: 2.5, interval: 365, consecutiveCorrect: 10, incorrectCount: 0, nextReviewDate: new Date("2026-01-01"), } const result = calculateCorrectAnswer(progress, new Date("2026-01-01")) // 365 * 2.5 = 912.5, rounded = 913 expect(result.interval).toBe(913) expect(result.consecutiveCorrect).toBe(11) }) }) describe("calculateIncorrectAnswer", () => { it("should reset interval to 1", () => { const progress: CardProgress = { easeFactor: 2.7, interval: 16, consecutiveCorrect: 3, incorrectCount: 0, nextReviewDate: new Date("2025-01-17"), } const result = calculateIncorrectAnswer(progress, new Date("2025-01-17")) expect(result.interval).toBe(1) expect(result.nextReviewDate).toEqual(new Date("2025-01-18")) }) it("should reset consecutiveCorrect to 0", () => { const progress: CardProgress = { easeFactor: 2.7, interval: 16, consecutiveCorrect: 5, incorrectCount: 1, nextReviewDate: new Date("2025-01-17"), } const result = calculateIncorrectAnswer(progress, new Date("2025-01-17")) expect(result.consecutiveCorrect).toBe(0) }) it("should decrease easeFactor by 0.2", () => { const progress: CardProgress = { easeFactor: 2.5, interval: 6, consecutiveCorrect: 2, incorrectCount: 0, nextReviewDate: new Date("2025-01-07"), } const result = calculateIncorrectAnswer(progress, new Date("2025-01-07")) expect(result.easeFactor).toBe(2.3) // 2.5 - 0.2 }) it("should not decrease easeFactor below 1.3", () => { const progress: CardProgress = { easeFactor: 1.4, interval: 1, consecutiveCorrect: 0, incorrectCount: 5, nextReviewDate: new Date("2025-01-02"), } const result = calculateIncorrectAnswer(progress, new Date("2025-01-02")) // 1.4 - 0.2 = 1.2, but minimum is 1.3 expect(result.easeFactor).toBe(1.3) }) it("should increment incorrectCount", () => { const progress: CardProgress = { easeFactor: 2.5, interval: 6, consecutiveCorrect: 2, incorrectCount: 0, nextReviewDate: new Date("2025-01-07"), } const result = calculateIncorrectAnswer(progress, new Date("2025-01-07")) expect(result.incorrectCount).toBe(1) }) it("should use current date by default", () => { const progress: CardProgress = { easeFactor: 2.5, interval: 6, consecutiveCorrect: 2, incorrectCount: 0, nextReviewDate: new Date("2025-01-07"), } const before = new Date() const result = calculateIncorrectAnswer(progress) const after = new Date() // Next review should be approximately 1 day from now const expectedMin = new Date(before) expectedMin.setDate(expectedMin.getDate() + 1) const expectedMax = new Date(after) expectedMax.setDate(expectedMax.getDate() + 1) expect(result.nextReviewDate.getTime()).toBeGreaterThanOrEqual( expectedMin.getTime() ) expect(result.nextReviewDate.getTime()).toBeLessThanOrEqual( expectedMax.getTime() ) }) it("should handle multiple consecutive incorrect answers", () => { let progress: CardProgress = { easeFactor: 2.5, interval: 16, consecutiveCorrect: 3, incorrectCount: 0, nextReviewDate: new Date("2025-01-17"), } // First incorrect let result = calculateIncorrectAnswer(progress, new Date("2025-01-17")) expect(result.easeFactor).toBe(2.3) expect(result.incorrectCount).toBe(1) // Second incorrect progress = { easeFactor: result.easeFactor, interval: result.interval, consecutiveCorrect: result.consecutiveCorrect, incorrectCount: result.incorrectCount, nextReviewDate: result.nextReviewDate, } result = calculateIncorrectAnswer(progress, new Date("2025-01-18")) expect(result.easeFactor).toBeCloseTo(2.1) // 2.3 - 0.2 expect(result.incorrectCount).toBe(2) // Third incorrect progress = { easeFactor: result.easeFactor, interval: result.interval, consecutiveCorrect: result.consecutiveCorrect, incorrectCount: result.incorrectCount, nextReviewDate: result.nextReviewDate, } result = calculateIncorrectAnswer(progress, new Date("2025-01-19")) expect(result.easeFactor).toBeCloseTo(1.9) // 2.1 - 0.2 expect(result.incorrectCount).toBe(3) }) }) describe("selectCardsForSession", () => { const now = new Date("2025-01-15T10:00:00Z") it("should select due cards only", () => { const cards: SelectableCard[] = [ { id: "1", nextReviewDate: new Date("2025-01-14T10:00:00Z"), // Due incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-16T10:00:00Z"), // Not due incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, { id: "3", nextReviewDate: new Date("2025-01-13T10:00:00Z"), // Due incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, ] const selected = selectCardsForSession(cards, 10, now, false) expect(selected.length).toBe(2) expect(selected.map((c) => c.id)).toContain("1") expect(selected.map((c) => c.id)).toContain("3") expect(selected.map((c) => c.id)).not.toContain("2") }) it("should exclude suspended cards", () => { const cards: SelectableCard[] = [ { id: "1", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.SUSPENDED, }, ] const selected = selectCardsForSession(cards, 10, now, false) expect(selected.length).toBe(1) expect(selected[0].id).toBe("1") }) it("should prioritize HARD over NORMAL over EASY", () => { const cards: SelectableCard[] = [ { id: "easy", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.EASY, }, { id: "hard", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.HARD, }, { id: "normal", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, ] const selected = selectCardsForSession(cards, 10, now, false) expect(selected[0].id).toBe("hard") expect(selected[1].id).toBe("normal") expect(selected[2].id).toBe("easy") }) it("should sort by nextReviewDate ascending", () => { const cards: SelectableCard[] = [ { id: "1", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-12T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, { id: "3", nextReviewDate: new Date("2025-01-13T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, ] const selected = selectCardsForSession(cards, 10, now, false) expect(selected[0].id).toBe("2") // Oldest expect(selected[1].id).toBe("3") expect(selected[2].id).toBe("1") // Newest }) it("should prioritize higher incorrectCount", () => { const cards: SelectableCard[] = [ { id: "1", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 1, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 3, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, { id: "3", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 2, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, ] const selected = selectCardsForSession(cards, 10, now, false) expect(selected[0].id).toBe("2") // incorrectCount: 3 expect(selected[1].id).toBe("3") // incorrectCount: 2 expect(selected[2].id).toBe("1") // incorrectCount: 1 }) it("should prioritize lower consecutiveCorrect", () => { const cards: SelectableCard[] = [ { id: "1", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 3, manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, { id: "3", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 2, manualDifficulty: Difficulty.MEDIUM, }, ] const selected = selectCardsForSession(cards, 10, now, false) expect(selected[0].id).toBe("2") // consecutiveCorrect: 1 expect(selected[1].id).toBe("3") // consecutiveCorrect: 2 expect(selected[2].id).toBe("1") // consecutiveCorrect: 3 }) it("should include new cards (null nextReviewDate)", () => { const cards: SelectableCard[] = [ { id: "1", nextReviewDate: null, // New card incorrectCount: 0, consecutiveCorrect: 0, manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, ] const selected = selectCardsForSession(cards, 10, now, false) expect(selected.length).toBe(2) expect(selected[0].id).toBe("1") // New cards first expect(selected[1].id).toBe("2") }) it("should limit to cardsPerSession", () => { const cards: SelectableCard[] = Array.from({ length: 20 }, (_, i) => ({ id: `${i}`, nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, })) const selected = selectCardsForSession(cards, 5, now, false) expect(selected.length).toBe(5) }) it("should apply all sorting criteria in correct order", () => { const cards: SelectableCard[] = [ { id: "easy-old-high-low", nextReviewDate: new Date("2025-01-12T10:00:00Z"), incorrectCount: 5, consecutiveCorrect: 1, manualDifficulty: Difficulty.EASY, }, { id: "hard-new-low-high", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 1, consecutiveCorrect: 5, manualDifficulty: Difficulty.HARD, }, { id: "hard-old-low-low", nextReviewDate: new Date("2025-01-12T10:00:00Z"), incorrectCount: 1, consecutiveCorrect: 1, manualDifficulty: Difficulty.HARD, }, { id: "normal-old-high-low", nextReviewDate: new Date("2025-01-12T10:00:00Z"), incorrectCount: 5, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, ] const selected = selectCardsForSession(cards, 10, now, false) // Expected order: // 1. HARD difficulty has priority // 2. Among HARD: older date (2025-01-12) before newer (2025-01-14) // 3. Then NORMAL difficulty // 4. Then EASY difficulty expect(selected[0].id).toBe("hard-old-low-low") expect(selected[1].id).toBe("hard-new-low-high") expect(selected[2].id).toBe("normal-old-high-low") expect(selected[3].id).toBe("easy-old-high-low") }) it("should handle empty card list", () => { const selected = selectCardsForSession([], 10, now, false) expect(selected.length).toBe(0) }) it("should handle all cards being suspended", () => { const cards: SelectableCard[] = [ { id: "1", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.SUSPENDED, }, { id: "2", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.SUSPENDED, }, ] const selected = selectCardsForSession(cards, 10, now, false) expect(selected.length).toBe(0) }) it("should handle all cards not being due", () => { const cards: SelectableCard[] = [ { id: "1", nextReviewDate: new Date("2025-01-16T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-17T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, manualDifficulty: Difficulty.MEDIUM, }, ] const selected = selectCardsForSession(cards, 10, now, false) expect(selected.length).toBe(0) }) }) describe("generateWrongAnswers", () => { const correctAnswer: HanziOption = { id: "1", simplified: "好", pinyin: "hǎo", hskLevel: "new-1", } it("should generate 3 wrong answers", () => { const sameHskOptions: HanziOption[] = [ correctAnswer, { id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" }, { id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" }, { id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" }, { id: "5", simplified: "的", pinyin: "de", hskLevel: "new-1" }, ] const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions) expect(wrongAnswers.length).toBe(3) expect(wrongAnswers).not.toContain("hǎo") // Should not include correct }) it("should not include correct answer", () => { const sameHskOptions: HanziOption[] = [ correctAnswer, { id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" }, { id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" }, { id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" }, ] const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions) expect(wrongAnswers).not.toContain("hǎo") }) it("should not include duplicate pinyin", () => { const sameHskOptions: HanziOption[] = [ correctAnswer, { id: "2", simplified: "好", pinyin: "hǎo", hskLevel: "new-1" }, // Duplicate pinyin { id: "3", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" }, { id: "4", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" }, { id: "5", simplified: "他", pinyin: "tā", hskLevel: "new-1" }, { id: "6", simplified: "的", pinyin: "de", hskLevel: "new-1" }, ] const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions) expect(wrongAnswers.length).toBe(3) // Should only have 1 instance of each pinyin const uniquePinyin = new Set(wrongAnswers) expect(uniquePinyin.size).toBe(3) }) it("should throw error if not enough options", () => { const sameHskOptions: HanziOption[] = [ correctAnswer, { id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" }, { id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" }, // Only 2 other options, need 3 ] expect(() => generateWrongAnswers(correctAnswer, sameHskOptions) ).toThrow("Not enough wrong answers available") }) it("should randomize the selection", () => { const sameHskOptions: HanziOption[] = [ correctAnswer, { id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" }, { id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" }, { id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" }, { id: "5", simplified: "的", pinyin: "de", hskLevel: "new-1" }, { id: "6", simplified: "是", pinyin: "shì", hskLevel: "new-1" }, { id: "7", simplified: "在", pinyin: "zài", hskLevel: "new-1" }, { id: "8", simplified: "有", pinyin: "yǒu", hskLevel: "new-1" }, ] // Run multiple times and check that we get different results const results = new Set() for (let i = 0; i < 10; i++) { const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions) results.add(wrongAnswers.sort().join(",")) } // Should have at least 2 different combinations (very likely with 7 options) expect(results.size).toBeGreaterThan(1) }) }) describe("shuffleOptions", () => { it("should return array of same length", () => { const options = ["a", "b", "c", "d"] const shuffled = shuffleOptions(options) expect(shuffled.length).toBe(4) }) it("should contain all original elements", () => { const options = ["a", "b", "c", "d"] const shuffled = shuffleOptions(options) expect(shuffled).toContain("a") expect(shuffled).toContain("b") expect(shuffled).toContain("c") expect(shuffled).toContain("d") }) it("should not mutate original array", () => { const options = ["a", "b", "c", "d"] const original = [...options] shuffleOptions(options) expect(options).toEqual(original) }) it("should produce different orders", () => { const options = ["a", "b", "c", "d", "e", "f"] // Run multiple times and check that we get different results const results = new Set() for (let i = 0; i < 20; i++) { const shuffled = shuffleOptions(options) results.add(shuffled.join(",")) } // Should have at least 2 different orderings (very likely with 6 elements) expect(results.size).toBeGreaterThan(1) }) it("should handle single element array", () => { const options = ["a"] const shuffled = shuffleOptions(options) expect(shuffled).toEqual(["a"]) }) it("should handle empty array", () => { const options: string[] = [] const shuffled = shuffleOptions(options) expect(shuffled).toEqual([]) }) it("should work with different types", () => { const options = [1, 2, 3, 4, 5] const shuffled = shuffleOptions(options) expect(shuffled.length).toBe(5) expect(shuffled).toContain(1) expect(shuffled).toContain(2) expect(shuffled).toContain(3) expect(shuffled).toContain(4) expect(shuffled).toContain(5) }) }) })