Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions src/__tests__/hardestFacts.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<SessionQuestionLog>[] | 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 });
});
});
12 changes: 12 additions & 0 deletions src/i18n/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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: [
Expand Down
84 changes: 62 additions & 22 deletions src/lib/hardestFacts.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<string, number> {
const errors = new Map<string, number>();
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;
}

Expand All @@ -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);
}
Loading