Files
Ustagammon/tests/browser/lecons.mjs
T
Pierre MARTINandClaude Opus 5.5 351482fa9e Recette 1g : textes corrigés, boutons en bas, barre haute en paysage
Leçon 1 §2 (flèche 7 vide, 5 déjà bloqué), leçon 2 « Faire des points »,
flèches 7 à 12, reformulations des leçons 3 et 4. Écran des leçons et du
tutoriel : « Retour » et « Continuer » en bas en portrait, « Revoir » sur
la ligne du titre, ligne « Leçon n · … » gardée en paysage. Barre haute
visible hors de l'écran de jeu en paysage (défaut antérieur à 1g), avec
un contrôle dans la recette automatique. Rappel des règles haut de
44 px ; « Aucune leçon terminée sur 5 · … » sur l'accueil.

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

302 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lecons.mjs -- les leçons (étape 1g) dans un vrai navigateur sans écran
// (Playwright), sur le site servi par l'image de production : la carte
// « prochaine leçon » de l'accueil, la liste des leçons, un écran
// d'explication, un exercice refusé puis réussi, la progression gardée après
// un rechargement, la fin d'une leçon et une adresse de leçon inconnue.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node lecons.mjs <adresse du site> <chromium|firefox> <dossier des
// captures>. Lancé par tests/browser.sh. Captures de la recette (Chromium)
// dans <dossier des captures>/lecons/, en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar, missingTopbar, tallButtons } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node lecons.mjs <adresse> <chromium|firefox> <captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/lecons`;
mkdirSync(dir, { recursive: true });
const SIZES = [
[412, 915],
[915, 412],
];
const browser = await browserType.launch();
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
/** Attend que test (évalué dans la page) soit vrai, au plus ms. (Pas de
* waitForFunction : Playwright y passe par eval, que la CSP interdit.) */
async function until(page, what, test, arg, ms = 30_000) {
let last = null;
for (const end = Date.now() + ms; Date.now() < end; ) {
last = await page.evaluate(test, arg).catch((error) => `page illisible : ${error.message}`);
if (last === true) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${shots}/${browserName}-lecons-echec.png` }).catch(() => {});
throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`);
}
/** Range une capture de l'écran courant dans chaque taille (Chromium), avec
* la recette : rien sous une barre d'action, aucun bouton étiré. */
async function capture(page, name, settle = async () => {}) {
if (!full) {
return;
}
for (const [width, height] of SIZES) {
await page.setViewportSize({ width, height });
await sleep(300);
await settle();
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`);
const topbar = await missingTopbar(page);
check(topbar === null, `${name} : ${topbar}`);
const tall = await tallButtons(page);
check(tall.length === 0, `${name} ${width}x${height} : boutons étirés : ${tall.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
captures++;
}
await page.setViewportSize({ width: 412, height: 915 });
await sleep(150);
}
/** L'écran d'une leçon prêt : plateau dessiné pour cette adresse, coup
* illustré joué (« Revoir » utilisable) s'il y en a un. */
async function lessonReady(page, hash) {
await until(page, `leçon ${hash}`, (hash) => {
const section = document.getElementById("lesson");
const replay = document.getElementById("lesson-replay");
return location.hash === hash && section.dataset.ready !== undefined &&
!section.hidden &&
document.querySelectorAll("#lesson-board polygon[data-point]").length === 24 &&
(replay.hidden || !replay.disabled);
}, hash);
}
/** Touche des cases du plateau de la leçon, groupe par groupe (un groupe :
* un demi-coup). */
async function playTouches(page, touches) {
for (const group of touches) {
for (const place of group) {
await page.click(`#lesson-board [data-place="${place}"]`);
await sleep(200);
}
await until(page, "demi-coup joué", () => !document.querySelector("#lesson[data-busy]"));
await sleep(150);
}
}
async function verdict(page) {
await until(page, "verdict", () => {
const v = document.getElementById("lesson-feedback").dataset.verdict;
return v === "right" || v === "wrong";
});
return page.evaluate(() => document.getElementById("lesson-feedback").dataset.verdict);
}
/** L'état affiché de chaque carte de la liste. */
async function listStates(page) {
return page.evaluate(() =>
[...document.querySelectorAll("#lesson-list .lesson-card")].map((card) => ({
id: card.id,
current: card.classList.contains("current"),
state: card.querySelector(".lesson-card-state")?.textContent ?? "",
done: card.querySelectorAll(".lesson-progress .segment.done").length,
})));
}
const context = await browser.newContext({
viewport: { width: 412, height: 915 },
deviceScaleFactor: 1,
locale: "fr-FR",
});
await context.addInitScript(() => {
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
// Le premier lancement est passé : l'accueil complet.
if (localStorage.getItem("ustagammon.settings") === null) {
localStorage.setItem("ustagammon.settings", JSON.stringify({ welcomed: true, tutorialSeen: 99 }));
}
});
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
page.on("dialog", (dialog) => {
problems.push(`boîte native : ${dialog.type()}`);
void dialog.dismiss();
});
try {
// ------------------------------------------- La carte de l'accueil
await page.goto(`${base}#/accueil`);
await until(page, "carte de la leçon", () => {
const card = document.getElementById("home-lesson");
return location.hash === "#/accueil" && !card.hidden && !card.closest("[hidden]") &&
card.textContent.includes("Leçon 1") && !document.getElementById("home-new").disabled &&
document.getElementById("home-progress").textContent.includes(" · ");
});
const home = await page.evaluate(() => ({
go: document.getElementById("home-lesson-go").getAttribute("href"),
goText: document.getElementById("home-lesson-go").textContent,
goClass: document.getElementById("home-lesson-go").className,
all: document.getElementById("home-lessons").getAttribute("href"),
title: document.querySelector("#home-lesson .lesson-card-title").textContent,
state: document.querySelector("#home-lesson .lesson-card-state").textContent,
summary: document.getElementById("home-progress").textContent,
primaries: [...document.querySelectorAll("#home a.button:not(.secondary), #home button:not(.secondary):not(.icon-button)")]
.filter((el) => !el.closest("[hidden]")).length,
}));
check(home.go === "#/lecons/1/1" && home.goText === "Commencer" && home.goClass.includes("secondary") &&
home.all === "#/lecons" && home.title === "Les ouvertures" && home.state === "À commencer · 4 exercices" &&
home.summary.startsWith("Aucune leçon terminée sur 5 · ") && home.primaries === 1,
`carte de l'accueil : ${JSON.stringify(home)}`);
await capture(page, "01-accueil-carte", async () => {
await page.locator("#home-lesson").scrollIntoViewIfNeeded();
});
log("accueil : carte « prochaine leçon » (leçon 1, à commencer), « Commencer » en secondaire");
// --------------------------------------------------------- La liste
await page.click("#home-lessons");
await until(page, "liste des leçons", () => location.hash === "#/lecons" &&
document.querySelectorAll("#lesson-list .lesson-card").length === 5);
const list = await listStates(page);
check(list[0].current && list.filter((c) => c.current).length === 1 &&
list.every((c) => c.state.startsWith("À commencer")), `liste : ${JSON.stringify(list)}`);
const primaries = await page.evaluate(() =>
[...document.querySelectorAll("#lessons a.button:not(.secondary)")].filter((el) => !el.closest("[hidden]")).length);
check(primaries === 1, `liste : ${primaries} actions principales`);
check(await page.getAttribute("#lessons-rules", "href") === "#/tutoriel/1", "liste : rappel des règles");
const rulesHeight = await page.evaluate(() => document.getElementById("lessons-rules").getBoundingClientRect().height);
check(rulesHeight >= 44, `liste : rappel des règles haut de ${rulesHeight} px`);
await capture(page, "02-liste");
log("liste : cinq leçons, la première proposée, une seule action principale, rappel des règles");
// ------------------------------------------- Un écran d'explication
await page.click("#lesson-card-1 .lesson-card-action");
await lessonReady(page, "#/lecons/1/1");
const explain = await page.evaluate(() => ({
overline: document.getElementById("lesson-overline").textContent,
title: document.getElementById("lesson-title").textContent,
segments: document.querySelectorAll("#lesson-progress .segment").length,
skip: document.getElementById("lesson-skip").getAttribute("href"),
back: document.getElementById("lesson-back").getAttribute("href"),
next: document.getElementById("lesson-next").getAttribute("href"),
topbar: getComputedStyle(document.querySelector(".topbar")).display,
glossary: [...document.querySelectorAll("#lesson-text a")].map((a) => a.getAttribute("href")),
}));
check(explain.overline === "Leçon 1 · 1 sur 4" && explain.title === "Un point dès le premier coup" &&
explain.segments === 4 && explain.skip === "#/lecons" && explain.back === "#/lecons" &&
explain.next === "#/lecons/1/1/a-vous" && explain.topbar === "none" &&
explain.glossary.includes("#/glossaire/jan-interieur"), `explication : ${JSON.stringify(explain)}`);
// « Revoir » sur la ligne du titre ; « Retour » et « Continuer » en bas
// de l'écran, sous le pouce (recette 1g).
const layout = await page.evaluate(() => {
const title = document.getElementById("lesson-title").getBoundingClientRect();
const replay = document.getElementById("lesson-replay").getBoundingClientRect();
const next = document.getElementById("lesson-next").getBoundingClientRect();
return { titleTop: title.top, titleBottom: title.bottom, replayTop: replay.top,
replayBottom: replay.bottom, nextBottom: next.bottom, height: innerHeight };
});
check(layout.replayTop < layout.titleBottom && layout.replayBottom > layout.titleTop,
`« Revoir » hors de la ligne du titre : ${JSON.stringify(layout)}`);
check(layout.nextBottom > layout.height - 64, `« Continuer » loin du bas : ${JSON.stringify(layout)}`);
check(await page.locator("#lesson-board .trace-arrow").count() > 0, "le coup illustré n'a pas laissé de traces");
await capture(page, "03-explication");
log("explication : coup illustré 8/5 6/5 joué tout seul, barre de quatre étapes, liens du glossaire");
// ------------------------------------------- Un exercice « à vous »
await page.click("#lesson-next");
await lessonReady(page, "#/lecons/1/1/a-vous");
// 13/11 : ne mène pas à 8/4 6/4, refusé.
await playTouches(page, [["p13", "p11"]]);
check(await verdict(page) === "wrong", "exercice : 13/11 accepté");
await until(page, "pion revenu", () => document.querySelectorAll("#lesson-board .hit.can-move").length > 0);
const refused = await page.textContent("#lesson-feedback");
check(refused.startsWith("Ce coup est permis, mais") && !/erreur/i.test(refused), `refus : ${refused}`);
await capture(page, "04-exercice-refuse");
await playTouches(page, [["p8", "p4"], ["p6"]]);
check(await verdict(page) === "right", "exercice : 8/4 6/4 refusé");
check(await page.locator("#lesson-board .hit.can-move").count() === 0, "plateau encore jouable après le bon coup");
const right = await page.textContent("#lesson-feedback");
check(right.includes("8/4 6/4"), `réussite : ${right}`);
check(!(await page.evaluate(() => document.getElementById("lesson-next").classList.contains("secondary"))),
"« Continuer » pas principal après le bon coup");
await capture(page, "05-exercice-reussi");
log("exercice : 13/11 refusé avec bienveillance, 8/4 6/4 salué");
// ------------------------------------ La progression, gardée
await page.goto(`${base}#/lecons`);
await page.reload();
await until(page, "liste après rechargement", () => location.hash === "#/lecons" &&
document.querySelectorAll("#lesson-list .lesson-card").length === 5 &&
document.querySelector("#lesson-card-1 .lesson-card-state")?.textContent === "1 exercice sur 4");
const after = await listStates(page);
check(after[0].current && after[0].done === 1, `liste après l'exercice : ${JSON.stringify(after)}`);
await page.goto(`${base}#/accueil`);
await until(page, "accueil après l'exercice", () =>
document.querySelector("#home-lesson .lesson-card-state")?.textContent === "1 exercice sur 4" &&
document.getElementById("home-lesson-go").textContent === "Continuer" &&
document.getElementById("home-lesson-go").getAttribute("href") === "#/lecons/1/1");
log("progression : « 1 exercice sur 4 » après rechargement, « Continuer » sur l'accueil");
// --------------------------------------- La fin d'une leçon
await page.goto(`${base}#/lecons/1/fin`);
await lessonReady(page, "#/lecons/1/fin");
const end = await page.evaluate(() => ({
overline: document.getElementById("lesson-overline").textContent,
next: document.getElementById("lesson-next").getAttribute("href"),
text: document.getElementById("lesson-next").textContent,
}));
check(end.overline === "Leçon 1 · fin" && end.next === "#/lecons/2/1" && end.text === "Leçon suivante",
`fin : ${JSON.stringify(end)}`);
await page.click("#lesson-next");
await lessonReady(page, "#/lecons/2/1");
log("fin : « Leçon suivante » mène à la leçon 2");
// Une leçon inconnue : la liste ; une étape inconnue : la première.
await page.goto(`${base}#/lecons/9/1`);
await until(page, "leçon inconnue", () => location.hash === "#/lecons" &&
document.querySelectorAll("#lesson-list .lesson-card").length === 5);
await page.goto(`${base}#/lecons/3/9`);
await lessonReady(page, "#/lecons/3/1");
log("adresses : leçon inconnue vers la liste, étape inconnue vers la première");
const csp = await page.evaluate(() => window.__csp);
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
check(problems.length === 0, problems.join("\n"));
log(`leçons : OK${full ? `, ${captures} captures dans ${dir}` : ""}`);
} catch (error) {
console.error(`[${browserName}] ÉCHEC : ${error.message}`);
if (problems.length > 0) {
console.error(problems.join("\n"));
}
await browser.close();
process.exit(1);
}
await browser.close();