diff --git a/src/__tests__/hardestFacts.test.ts b/src/__tests__/hardestFacts.test.ts new file mode 100644 index 00000000..535b3cd5 --- /dev/null +++ b/src/__tests__/hardestFacts.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment node +import { describe, it, expect } from 'vitest'; +import { getHardestFacts } from '../lib/hardestFacts'; +import { createInitialFacts } from '../lib/facts'; +import { createInitialDivisionFacts } from '../lib/divisionFacts'; +import type { UserProfile, SessionResult, SessionQuestionLog } from '../types'; + +function makeProfile(overrides: Partial = {}): UserProfile { + return { + name: 'Zoe', + startDate: '2026-01-01', + facts: createInitialFacts(), + totalSessions: 0, + currentStreak: 0, + longestStreak: 0, + lastSessionDate: null, + streakFreezes: 0, + badges: [], + sessionHistory: [], + hasSeenRulesIntro: true, + hasSeenRule11: false, + mysteryTheme: 'market', + ...overrides, + }; +} + +function makeSession( + date: string, + questions: Partial[] | undefined, +): SessionResult { + const qs = questions?.map((q) => ({ + a: 2, + b: 3, + correct: true, + responseTimeMs: 2000, + answeredWith: null, + isBonusReview: false, + inputMode: 'keypad' as const, + ...q, + })); + return { + date, + questionsCount: qs?.length ?? 10, + correctCount: qs?.filter((q) => q.correct).length ?? 10, + averageTimeMs: 2000, + newFactsIntroduced: 0, + factsPromoted: 0, + ...(qs ? { questions: qs } : {}), + }; +} + +function introduce(profile: UserProfile, a: number, b: number): void { + const fact = profile.facts.find((f) => f.a === a && f.b === b)!; + fact.introduced = true; +} + +describe('getHardestFacts — comptage depuis les logs de séance', () => { + it('compte les erreurs des révisions bonus (absentes de fact.history)', () => { + // Bug historique : une erreur en révision bonus baissait le taux de bonnes + // réponses de la séance mais n'apparaissait jamais dans les faits les plus + // difficiles (fact.history n'enregistre pas les révisions bonus). + const profile = makeProfile(); + introduce(profile, 7, 8); + profile.sessionHistory = [ + makeSession('2026-07-19', [ + { a: 7, b: 8, correct: false, isBonusReview: true }, + ]), + ]; + + const hard = getHardestFacts(profile, 10, 5); + expect(hard).toHaveLength(1); + expect(hard[0]).toMatchObject({ kind: 'mult', a: 7, b: 8, errorCount: 1 }); + }); + + it('ignore les erreurs des séances hors de la fenêtre', () => { + const profile = makeProfile(); + introduce(profile, 7, 8); + introduce(profile, 6, 9); + profile.sessionHistory = [ + makeSession('2026-07-01', [{ a: 7, b: 8, correct: false }]), + makeSession('2026-07-02', [{ a: 6, b: 9, correct: false }]), + makeSession('2026-07-03', []), + ]; + + const hard = getHardestFacts(profile, 2, 5); + expect(hard).toHaveLength(1); + expect(hard[0]).toMatchObject({ kind: 'mult', a: 6, b: 9 }); + }); + + it('cumule les erreurs sur plusieurs séances et trie par erreurs décroissantes', () => { + const profile = makeProfile(); + introduce(profile, 7, 8); + introduce(profile, 6, 9); + profile.sessionHistory = [ + makeSession('2026-07-18', [ + { a: 7, b: 8, correct: false }, + { a: 6, b: 9, correct: false }, + ]), + makeSession('2026-07-19', [{ a: 7, b: 8, correct: false, isBonusReview: true }]), + ]; + + const hard = getHardestFacts(profile, 10, 5); + expect(hard.map((f) => [f.key, f.errorCount])).toEqual([ + ['7x8', 2], + ['6x9', 1], + ]); + }); + + it('mappe les logs division (a = diviseur, b = quotient) sur le bon fait', () => { + const profile = makeProfile({ divisionFacts: createInitialDivisionFacts() }); + const fact = profile.divisionFacts!.find((f) => f.dividend === 24 && f.divisor === 3)!; + fact.introduced = true; + fact.box = 4; + profile.sessionHistory = [ + makeSession('2026-07-20', [{ kind: 'div', a: 3, b: 8, correct: false }]), + ]; + + const hard = getHardestFacts(profile, 10, 5); + expect(hard).toHaveLength(1); + expect(hard[0]).toMatchObject({ + kind: 'div', + dividend: 24, + divisor: 3, + quotient: 8, + box: 4, + errorCount: 1, + }); + }); + + it('exclut les faits sans erreur et tronque à limit', () => { + const profile = makeProfile(); + const pairs: Array<[number, number]> = [ + [2, 3], + [2, 4], + [2, 5], + [2, 6], + [2, 7], + [2, 8], + ]; + for (const [a, b] of pairs) introduce(profile, a, b); + profile.sessionHistory = [ + makeSession( + '2026-07-20', + pairs.map(([a, b]) => ({ a, b, correct: false })), + ), + ]; + + const hard = getHardestFacts(profile, 10, 5); + expect(hard).toHaveLength(5); + expect(hard.every((f) => f.errorCount > 0)).toBe(true); + }); + + it('repli fact.history quand aucune séance de la fenêtre n’a de log', () => { + const profile = makeProfile(); + introduce(profile, 7, 8); + const fact = profile.facts.find((f) => f.a === 7 && f.b === 8)!; + fact.history = [ + { date: '2026-07-19', correct: false, responseTimeMs: 3000, answeredWith: 54 }, + ]; + profile.sessionHistory = [makeSession('2026-07-19', undefined)]; + + const hard = getHardestFacts(profile, 10, 5); + expect(hard).toHaveLength(1); + expect(hard[0]).toMatchObject({ kind: 'mult', a: 7, b: 8, errorCount: 1 }); + }); +}); diff --git a/src/i18n/changelog.ts b/src/i18n/changelog.ts index 2bba5285..03902602 100644 --- a/src/i18n/changelog.ts +++ b/src/i18n/changelog.ts @@ -8,6 +8,12 @@ import type { ChangelogEntry } from '../lib/changelog'; // parent (anglais adulte clair). Consommé par lib/changelog.ts via getLang(). const fr: ChangelogEntry[] = [ + { + date: '2026-07-20', + items: [ + "Espace parent : la liste « Faits les plus difficiles » oubliait les erreurs commises pendant les révisions bonus (les questions ajoutées pour compléter une séance courte). Le taux de bonnes réponses pouvait montrer des séances imparfaites alors que presque aucune erreur n'était listée. La liste reflète désormais toutes les erreurs des dernières séances, révisions bonus comprises.", + ], + }, { date: '2026-07-01', items: [ @@ -159,6 +165,12 @@ const fr: ChangelogEntry[] = [ ]; const en: ChangelogEntry[] = [ + { + date: '2026-07-20', + items: [ + 'Parent area: the "Hardest facts" list was missing mistakes made during bonus reviews (the extra questions added to fill out a short session). The accuracy chart could show imperfect sessions while almost no mistakes were listed. The list now reflects every mistake from recent sessions, bonus reviews included.', + ], + }, { date: '2026-07-01', items: [ diff --git a/src/lib/hardestFacts.ts b/src/lib/hardestFacts.ts index 010df86e..1bb85a82 100644 --- a/src/lib/hardestFacts.ts +++ b/src/lib/hardestFacts.ts @@ -1,4 +1,4 @@ -import type { UserProfile, BoxLevel, Attempt } from '../types'; +import type { UserProfile, BoxLevel, Attempt, SessionResult } from '../types'; import { getFactKey } from './facts'; import { getDivisionFactKey } from './divisionFacts'; @@ -8,7 +8,34 @@ export type HardFact = | { kind: 'mult'; key: string; box: BoxLevel; errorCount: number; a: number; b: number; product: number } | { kind: 'div'; key: string; box: BoxLevel; errorCount: number; dividend: number; divisor: number; quotient: number }; -function countErrors(history: Attempt[], cutoff: string | null): number { +// Erreurs par fait (clé préfixée `mult:`/`div:`) depuis les logs par-question +// des séances. C'est la MÊME source que le taux de bonnes réponses de l'espace +// parent (correctCount/questionsCount) : les révisions bonus y figurent, alors +// qu'elles sont absentes de `fact.history` (pas de changement Leitner). Compter +// depuis `fact.history` faisait « disparaître » des erreurs pourtant visibles +// dans le graphe de taux de réussite — précisément celles des révisions bonus, +// qui ciblent les faits les plus fragiles. +function countErrorsFromLogs(sessions: SessionResult[]): Map { + const errors = new Map(); + for (const s of sessions) { + for (const q of s.questions ?? []) { + if (q.correct) continue; + // Log 'div' : a = diviseur, b = quotient → dividende = a × b. + const key = + q.kind === 'div' + ? `div:${getDivisionFactKey(q.a * q.b, q.a)}` + : `mult:${getFactKey(q.a, q.b)}`; + errors.set(key, (errors.get(key) ?? 0) + 1); + } + } + return errors; +} + +// Repli pour les profils dont aucune séance de la fenêtre n'a de log +// par-question (séances antérieures à la feature) : ancien comptage depuis +// `fact.history`, borné par la date de la plus vieille séance de la fenêtre. +// Sous-compte les révisions bonus, mais évite une section vide sur ces profils. +function countErrorsFromHistory(history: Attempt[], cutoff: string | null): number { return history.filter((h) => !h.correct && (cutoff === null || h.date >= cutoff)).length; } @@ -27,35 +54,48 @@ export function getHardestFacts( limit: number, ): HardFact[] { const sessions = profile.sessionHistory; + const recent = sessions.slice(-windowSize); + const hasLogs = recent.some((s) => s.questions); + const logErrors = hasLogs ? countErrorsFromLogs(recent) : null; const cutoff = sessions.length > windowSize ? sessions[sessions.length - windowSize].date : null; const mult: HardFact[] = profile.facts .filter((f) => f.introduced) - .map((f) => ({ - kind: 'mult', - key: getFactKey(f.a, f.b), - box: f.box, - errorCount: countErrors(f.history, cutoff), - a: f.a, - b: f.b, - product: f.product, - })); + .map((f) => { + const key = getFactKey(f.a, f.b); + return { + kind: 'mult' as const, + key, + box: f.box, + errorCount: logErrors + ? (logErrors.get(`mult:${key}`) ?? 0) + : countErrorsFromHistory(f.history, cutoff), + a: f.a, + b: f.b, + product: f.product, + }; + }); const div: HardFact[] = (profile.divisionFacts ?? []) .filter((f) => f.introduced) - .map((f) => ({ - kind: 'div', - key: getDivisionFactKey(f.dividend, f.divisor), - box: f.box, - errorCount: countErrors(f.history, cutoff), - dividend: f.dividend, - divisor: f.divisor, - quotient: f.quotient, - })); + .map((f) => { + const key = getDivisionFactKey(f.dividend, f.divisor); + return { + kind: 'div' as const, + key, + box: f.box, + errorCount: logErrors + ? (logErrors.get(`div:${key}`) ?? 0) + : countErrorsFromHistory(f.history, cutoff), + dividend: f.dividend, + divisor: f.divisor, + quotient: f.quotient, + }; + }); return [...mult, ...div] + .filter((f) => f.errorCount > 0) .sort((a, b) => b.errorCount - a.errorCount || a.box - b.box) - .slice(0, limit) - .filter((f) => f.errorCount > 0); + .slice(0, limit); }