Files
Ustagammon/app/tests/review-runner.test.ts
T

152 lines
5.6 KiB
TypeScript

// L'analyse d'une partie en arrière-plan (app/src/ui/review-runner.ts) :
// une analyse à la fois, rangée au fur et à mesure, arrêtée à la demande
// ou par une autre partie ; et les phrases du bilan.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import {
type AnalysisTask,
type GameSummary,
missingAnalyses,
} from "../src/domain/analysis/game-analysis.js";
import { diceWords, nothingToReview, summaryReading } from "../src/domain/analysis/phrases.js";
import {
appendEvent,
EMPTY_JOURNAL,
type GameJournal,
type NewGameEvent,
} from "../src/domain/game/events.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { emptyReview, type GameReview } from "../src/domain/game/review.js";
import { createReviewRunner } from "../src/ui/review-runner.js";
import { loadPhrases } from "./support/advice.js";
const P = START_POSITION;
/** Un tour de l'ordinateur, sans décision du joueur. */
const EVENTS: NewGameEvent[] = [
{ type: "GameStarted", position: P, cube: { value: 1, owner: null }, firstTurn: "computer" },
{ type: "DiceRolled", side: "computer", roll: [6, 5] },
{ type: "MovePlayed", side: "computer", halfMoves: [], position: P },
];
const JOURNAL: GameJournal = EVENTS.reduce(appendEvent, EMPTY_JOURNAL);
/** Une fausse analyse : elle range la tâche sans moteur, quand on la
* libère. */
function fakeRun() {
const waiting: (() => void)[] = [];
const run = async (journal: GameJournal, task: AnalysisTask): Promise<GameJournal> => {
await new Promise<void>((resolve) => waiting.push(resolve));
const review: GameReview = journal.review ?? emptyReview(2);
if (task.kind === "luck") {
return {
...journal,
review: { ...review, luck: { [task.eventId]: { value: 0, rating: "none" } } },
};
}
if (task.kind === "chance") {
return {
...journal,
review: { ...review, winChances: { ...review.winChances, [task.eventId]: 0.5 } },
};
}
return { ...journal, review: { ...review, unanalysed: [task.eventId] } };
};
const release = async () => {
while (waiting.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
waiting.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 1));
};
return { run, release };
}
test("l'analyse en arrière-plan range chaque analyse, puis s'arrête une fois tout fait", async () => {
const { run, release } = fakeRun();
const runner = createReviewRunner(run);
const recorded: GameJournal[] = [];
let done = false;
// La chance du jet, le jet sans doubler de l'ordinateur, ses chances
// après le coup.
const total = missingAnalyses(JOURNAL).length;
assert.equal(total, 3);
runner.start(
"a",
JOURNAL,
(j) => recorded.push(j),
() => {
done = true;
},
);
assert.equal(runner.running, "a");
// La même partie redemandée ne repart pas.
runner.start("a", JOURNAL, () => assert.fail("deuxième analyse"));
for (let i = 0; i < total; i++) {
await release();
}
assert.equal(recorded.length, total);
assert.deepEqual(missingAnalyses(recorded.at(-1) as GameJournal), []);
assert.ok(done);
assert.equal(runner.running, null);
});
test("l'analyse s'arrête à la demande, ou pour une autre partie, sans ranger la dernière", async () => {
const { run, release } = fakeRun();
const runner = createReviewRunner(run);
const recorded: string[] = [];
runner.start("a", JOURNAL, () => recorded.push("a"));
runner.stop();
assert.equal(runner.running, null);
await release();
assert.equal(recorded.length, 0);
runner.start("b", JOURNAL, () => recorded.push("b"));
runner.start("c", JOURNAL, () => recorded.push("c"));
assert.equal(runner.running, "c");
await release();
await release();
assert.deepEqual(recorded, ["c"]);
});
/** Un bilan réduit à ce que lisent les phrases. */
function summaryWith(result: number, luckAdjustedResult: number): GameSummary {
const side = {
errorRate: { total: 0, decisions: 0, perDecision: 0, level: "champion" as const },
luck: { total: 0, rolls: 1, perRoll: -0.04, rating: "unlucky" as const, points: 0 },
};
return {
player: side,
computer: side,
counts: { good: 0, doubtful: 0, bad: 0, veryBad: 0 },
toReview: null,
result,
luckAdjustedResult,
};
}
test("les phrases du bilan : ce qui a fait la différence, et les dés en mots", () => {
const phrases = loadPhrases();
assert.equal(diceWords(phrases, "unlucky"), "un peu contre vous");
assert.equal(
summaryReading(phrases, summaryWith(1, 1.2)),
"D'après mon analyse, votre jeu a fait la différence, avec des dés un peu contre vous.",
);
assert.equal(
summaryReading(phrases, summaryWith(-2, -1.5)),
"D'après mon analyse, le jeu de l'ordinateur a fait la différence, avec des dés un peu contre vous.",
);
// Gagnée grâce aux dés, ou perdue à cause d'eux : les dés.
for (const [result, adjusted] of [
[1, -0.3],
[-1, 0.2],
] as const) {
assert.equal(
summaryReading(phrases, summaryWith(result, adjusted)),
"D'après mon analyse, les dés ont fait la différence, plus que le jeu.",
);
}
assert.ok(nothingToReview(phrases).startsWith("Aucun coup à revoir"));
});