Files
Ustagammon/app/tests/progression.test.ts
Pierre MARTINandClaude Opus 5.5 06cc20fb1b Recette 1h : « aucune gagnée », résultat avec gammon et videau
Le résultat d'une partie dit d'où viennent ses points (« Perdue,
gammon, videau à 2, 4 points ») ; la valeur du videau vient du journal
rejoué. Les « Perdue, 2 points » des captures étaient des parties
simples perdues avec un videau à 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:10:11 +01:00

267 lines
11 KiB
TypeScript

// Tests de la progression (étape 1h) : le taux d'erreur de chaque partie
// terminée est celui du bilan, lu dans les analyses rangées sans rien
// recalculer ; la courbe, sa tendance et la phrase du maître ; le prompt
// « Créer des exercices » sur plusieurs parties (thèmes des erreurs, niveau
// du joueur à partir de 20 décisions cumulées).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import {
missingAnalyses,
readyForSummary,
runAnalysis,
summarizeAnalysis,
} from "../src/domain/analysis/game-analysis.js";
import { PHRASE_KEYS, progressionSentence } from "../src/domain/analysis/phrases.js";
import {
CURVE_MIN_GAMES,
errorRateMillis,
type FinishedGame,
progressionCurve,
progressionEntries,
progressionTrend,
} from "../src/domain/analysis/progression.js";
import type { Engine } from "../src/domain/game/engine.js";
import type { GameJournal } from "../src/domain/game/events.js";
import { currentLine } from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { replay } from "../src/domain/game/state.js";
import {
exercisesPosition,
exercisesPrompt,
LEVEL_MIN_DECISIONS,
LEVEL_NAMES,
mistakeThemes,
} from "../src/domain/prompts/prompts.js";
import { PROMPT_FILES, parsePromptTemplate } from "../src/domain/prompts/templates.js";
import { HOME_TEXTS } from "../src/ui/home-texts.js";
import { PROGRESSION_TEXTS } from "../src/ui/progression-texts.js";
import { parseRoute, routeHash } from "../src/ui/router.js";
import { loadPhrases } from "./support/advice.js";
import { loadNodeEngine } from "./support/node-engine.js";
import { playToTheEnd, seededDice } from "./support/play.js";
const root = new URL("../../../", import.meta.url);
const phrases = loadPhrases();
const point = (id: string, perDecision: number) => ({ id, perDecision });
async function analyseAll(engine: Engine, journal: GameJournal): Promise<GameJournal> {
let current = journal;
for (let i = 0; i < 500; i++) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
throw new Error("l'analyse ne finit pas");
}
/** Une course courte jouée jusqu'au bout (le joueur prend le choice-ième
* coup légal), non analysée. */
async function race(engine: Engine, seed: number, choice: number): Promise<GameJournal> {
const game = Game.start(engine, {
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
level: "intermediate",
dice: seededDice(seed),
});
await playToTheEnd(game, engine, choice);
return game.journal;
}
function finished(id: string, startedAt: number, journal: GameJournal): FinishedGame {
return {
id,
startedAt,
level: "intermediate",
result: replay(currentLine(journal)).result,
journal,
};
}
test("l'adresse #/progression et celle de « Créer des exercices »", () => {
assert.deepEqual(parseRoute("#/progression"), { screen: "progression" });
assert.equal(routeHash({ screen: "progression", ai: true }), "#/progression/ia");
});
test("le résultat d'une partie dit d'où viennent les points : gammon, videau", () => {
const result = PROGRESSION_TEXTS.result;
const lost = (kind: "single" | "gammon" | "backgammon", points: number) =>
({ winner: "computer", kind, points }) as const;
assert.equal(result({ winner: "player", kind: "single", points: 1 }, 1), "Gagnée, 1 point");
assert.equal(result(lost("single", 1), 1), "Perdue, 1 point");
assert.equal(result(lost("gammon", 2), 1), "Perdue, gammon, 2 points");
assert.equal(result(lost("single", 2), 2), "Perdue, videau à 2, 2 points");
assert.equal(result(lost("gammon", 4), 2), "Perdue, gammon, videau à 2, 4 points");
assert.equal(result(lost("backgammon", 12), 4), "Perdue, backgammon, videau à 4, 12 points");
assert.equal(result(null, 1), "Partie terminée");
});
test("la ligne de l'accueil : « aucune gagnée », comme « aucune leçon terminée »", () => {
assert.equal(HOME_TEXTS.progress(3, 0), "3 parties jouées · aucune gagnée");
assert.equal(HOME_TEXTS.progress(3, 1), "3 parties jouées · 1 gagnée");
assert.equal(HOME_TEXTS.progress(0, 0), "aucune partie jouée");
});
test("le taux d'erreur s'écrit en millièmes, comme les mEMG de GNU Backgammon", () => {
assert.equal(errorRateMillis(0.0142), 14);
assert.equal(errorRateMillis(0.0075), 8);
assert.equal(errorRateMillis(0), 0);
});
test("la tendance : moyennes des deux moitiés, arrondies en millièmes", () => {
assert.deepEqual(progressionTrend([]), { kind: "waiting" });
assert.deepEqual(progressionTrend([point("a", 0.02), point("b", 0.01)]), { kind: "waiting" });
assert.equal(CURVE_MIN_GAMES, 3);
// Trois parties : la première contre la dernière, celle du milieu ne
// compte pas.
assert.deepEqual(progressionTrend([point("a", 0.014), point("b", 0.05), point("c", 0.007)]), {
kind: "down",
games: 3,
from: 14,
to: 7,
});
assert.deepEqual(
progressionTrend([
point("a", 0.01),
point("b", 0.012),
point("c", 0.02),
point("d", 0.016),
]),
{ kind: "up", games: 4, from: 11, to: 18 },
);
// 0,0101 et 0,0104 s'écrivent tous deux 10 : stable.
assert.equal(
progressionTrend([point("a", 0.0101), point("b", 0.03), point("c", 0.0104)]).kind,
"steady",
);
});
test("la phrase du maître : des faits, sans point d'exclamation, sous 140 caractères", () => {
for (const key of ["progression.baisse", "progression.hausse", "progression.stable"]) {
assert.ok(PHRASE_KEYS.includes(key), key);
}
assert.equal(
progressionSentence(phrases, { kind: "down", games: 8, from: 14, to: 7 }),
"Sur vos 8 dernières parties, vous faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7.",
);
assert.equal(
progressionSentence(phrases, { kind: "waiting" }),
"Votre courbe apparaîtra après trois parties.",
);
for (const trend of [
{ kind: "down", games: 20, from: 140, to: 70 },
{ kind: "up", games: 20, from: 70, to: 140 },
{ kind: "steady", games: 20, from: 12, to: 12 },
] as const) {
const sentence = progressionSentence(phrases, trend);
assert.ok(sentence.length < 140, sentence);
assert.ok(!sentence.includes("!"), sentence);
assert.ok(sentence.includes(String(trend.to)), sentence);
}
assert.ok(
progressionSentence(phrases, { kind: "up", games: 5, from: 7, to: 9 }).includes(
"Les leçons sont là",
),
);
});
test("la progression lit le taux du bilan, sans rien recalculer ; une partie non analysée attend", async () => {
const engine = await loadNodeEngine();
const raw = [await race(engine, 3, 3), await race(engine, 7, 2), await race(engine, 11, 1)];
const analysed = await Promise.all(raw.map((j) => analyseAll(engine, j)));
// Données dans le désordre : la progression les range par date.
const games = [
finished("c", 3000, analysed[2] as GameJournal),
finished("a", 1000, analysed[0] as GameJournal),
finished("b", 2000, analysed[1] as GameJournal),
finished("d", 4000, raw[0] as GameJournal),
];
const entries = progressionEntries(games);
assert.deepEqual(
entries.map((e) => e.id),
["a", "b", "c", "d"],
);
for (const [i, entry] of entries.slice(0, 3).entries()) {
assert.deepEqual(
entry.errorRate,
summarizeAnalysis(analysed[i] as GameJournal).player.errorRate,
);
}
assert.equal(readyForSummary(raw[0] as GameJournal), false);
assert.equal(entries[3]?.errorRate, null, "partie non analysée : pas de taux");
const curve = progressionCurve(entries);
assert.deepEqual(
curve.map((p) => p.id),
["a", "b", "c"],
);
assert.equal(progressionTrend(curve).kind === "waiting", false);
});
test("« Créer des exercices » : erreurs, thèmes comptés sur les faits du moteur, niveau à 20 décisions cumulées", async () => {
const engine = await loadNodeEngine();
const template = parsePromptTemplate(
readFileSync(new URL(`docs/prompts/${PROMPT_FILES.exercises}`, root), "utf8"),
);
const templates = { move: "", cube: "", summary: "", concept: "", exercises: template };
const journals: GameJournal[] = [];
for (const [seed, choice] of [
[3, 3],
[7, 2],
[11, 1],
[5, 4],
[9, 3],
] as const) {
journals.push(await analyseAll(engine, await race(engine, seed, choice)));
}
const decisions = journals.map((j) => summarizeAnalysis(j).player.errorRate.decisions);
assert.ok((decisions[0] ?? 0) < LEVEL_MIN_DECISIONS, `décisions : ${decisions.join()}`);
let cumulated = 0;
for (let n = 1; n <= journals.length; n++) {
const some = journals.slice(0, n);
cumulated += decisions[n - 1] ?? 0;
const position = await exercisesPosition(engine, some);
if (position === null) {
continue;
}
const prompt = exercisesPrompt(templates, phrases, { journals: some, position });
assert.ok(!/[{}]/.test(prompt), prompt);
assert.ok(prompt.includes("Ce qui revient dans ces erreurs"), prompt);
assert.match(prompt, /- \d+ coups? et \d+ décisions? de videau/);
if (cumulated >= LEVEL_MIN_DECISIONS) {
assert.match(prompt, /Je suis un joueur de niveau /);
assert.ok(Object.values(LEVEL_NAMES).some((name) => prompt.includes(name)));
} else {
assert.ok(prompt.includes("Je débute au backgammon."), prompt);
}
}
assert.ok(cumulated >= LEVEL_MIN_DECISIONS, `décisions cumulées : ${cumulated}`);
});
test("les thèmes des erreurs : faits du moteur seulement, une ligne par fait présent", () => {
const facts = (hits: number, madePoints: number[]) => ({ hits, madePoints });
const move = (best: ReturnType<typeof facts>, played: ReturnType<typeof facts>) => ({
advice: { kind: "move", bestFacts: best, playedFacts: played } as never,
});
const cube = { advice: { kind: "cube" } as never };
assert.deepEqual(
mistakeThemes([
move(facts(1, []), facts(0, [])),
move(facts(0, [5]), facts(0, [])),
move(facts(0, []), facts(1, [])),
cube,
]),
[
"- 3 coups et 1 décision de videau",
"- 1 fois, le coup de GNU Backgammon frappait un pion et le mien non",
"- 1 fois, le coup de GNU Backgammon faisait un point que le mien ne faisait pas",
"- 1 fois, mon coup frappait un pion et celui de GNU Backgammon non",
],
);
assert.deepEqual(mistakeThemes([cube]), ["- 0 coup et 1 décision de videau"]);
});