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>
302 lines
15 KiB
JavaScript
302 lines
15 KiB
JavaScript
// 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();
|