diff --git a/Makefile b/Makefile index cc88fe2..9df63ec 100644 --- a/Makefile +++ b/Makefile @@ -127,9 +127,9 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone # docs/identite/logo/ (une seule source, ADR 0010) ; # - l'application compilée (build/web/src/) sous app/ ; # - le moteur sous wasm/ ; -# - les copies du glossaire, des scénarios, du tutoriel, des phrases du +# - les copies du glossaire, des scénarios, du tutoriel, des leçons, des phrases du # maître et des prompts pour une IA sous docs/, lues par l'aide, le -# tutoriel, le conseil du maître et « Demander à une IA » +# tutoriel, les leçons, le conseil du maître et « Demander à une IA » # (ADR 0008 : jamais commitées # ailleurs) ; # - version.json (commit publié) ; @@ -143,7 +143,8 @@ site: engine-wasm web ## Assemble le site publié dans build/site/ cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \ $(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/ - cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre docs/prompts $(BUILD_DIR)/site/docs/ + cp -r docs/glossaire docs/scenarios docs/tutoriel docs/lecons docs/maitre docs/prompts \ + $(BUILD_DIR)/site/docs/ printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \ "$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json scripts/service-worker.sh $(BUILD_DIR)/site diff --git a/app/src/main.ts b/app/src/main.ts index cbcfc31..36fc1b2 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -65,7 +65,7 @@ const settings = createSettingsStore(localStorage ?? memoryStorage()); // pendant que le moteur se charge : le plateau est dessiné sans les // attendre. L'écran de jeu ne s'en sert qu'une fois engineReady résolue. const opening = (async () => { - const { library } = await openGameLibrary({ + const { library, lessons } = await openGameLibrary({ indexedDB: attempt(() => window.indexedDB), localStorage, }); @@ -76,7 +76,7 @@ const opening = (async () => { resume: (journal) => Game.resume(engine, journal), }); const saved = await session.restore(); - return { library, session, saved }; + return { library, session, saved, lessons }; })(); let stored: Awaited | null = null; /** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */ @@ -154,6 +154,14 @@ startApp({ MovePractice.start(engine, position, roll, expected), shouldTake: (position) => shouldTake(engine, position), }, + // La progression dans les leçons (étape 1g), dans le stockage des + // parties : lue une fois ouvert, enregistrée sans être attendue. + lessons: { + load: async () => (await opening).lessons.load(), + save: (progress) => { + void opening.then((opened) => opened.lessons.save(progress)); + }, + }, // Hors ligne : le service worker met tout le site en cache. Le navigateur // le refuse hors https (sauf localhost) : l'application marche alors sans. // Il est enregistré une fois le moteur chargé : si le Worker et le service diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index e1e4e8a..d6a8b09 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -44,6 +44,15 @@ import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/ import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { otherSide, type Side } from "../domain/game/side.js"; import type { GameState } from "../domain/game/state.js"; +import { + finishedLessons, + LESSON_END, + type LessonProgress, + lessonRecord, + NO_PROGRESS, + withSolved, + withVisit, +} from "../domain/learning/lessons.js"; import { ADVICE_TEXTS } from "./advice-texts.js"; import { type Analyse, createAdviceTracker } from "./advice-tracker.js"; import { createAdviceView, VERDICT_ICONS } from "./advice-view.js"; @@ -82,6 +91,10 @@ import { HOME_TEXTS } from "./home-texts.js"; import { createHomeView } from "./home-view.js"; import { icon } from "./icons.js"; import { installPlatform, startInstaller } from "./install.js"; +import { LESSON_COUNT, type Lesson, lessonSource, outlineOf, parseLesson } from "./lesson-text.js"; +import { LESSON_TEXTS } from "./lesson-texts.js"; +import { lessonCards, nextLessonCard } from "./lessons-model.js"; +import { createLessonsView } from "./lessons-view.js"; import { MATCH_TEXTS } from "./match-texts.js"; import { replacingLink, startNavigation } from "./navigation.js"; import { createNewGameView } from "./new-game-view.js"; @@ -106,8 +119,21 @@ import { } from "./settings.js"; import { createSummaryView } from "./summary-view.js"; import { type PromptText, TEXTS } from "./texts.js"; -import { seenAfter } from "./tutorial-nav.js"; -import { createTutorialView, type TutorialAccess } from "./tutorial-view.js"; +import { + lessonPlaceOf, + lessonPlaceRoute, + seenAfter, + placeOf as tutorialPlaceOf, + placeRoute as tutorialPlaceRoute, +} from "./tutorial-nav.js"; +import { parseTutorial, type Tutorial } from "./tutorial-text.js"; +import { + type Course, + cachedText, + createTutorialView, + TUTORIAL_SOURCE, + type TutorialAccess, +} from "./tutorial-view.js"; import { betweenTurns, type Updates, updateBannerShown } from "./updates.js"; /** Glissement d'un pion de l'ordinateur, et pause entre deux pions @@ -147,8 +173,15 @@ export interface AppOptions { /** Les réglages, en JSON (null : jamais enregistrés). */ readonly loadSettings: () => string | null; readonly saveSettings: (json: string) => void; - /** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur. */ + /** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur ; + * les leçons s'en servent aussi. */ readonly tutorial: TutorialAccess; + /** La progression dans les leçons (étape 1g), dans le stockage des + * parties. */ + readonly lessons: { + readonly load: () => Promise; + readonly save: (progress: LessonProgress) => void; + }; /** Les mises à jour de l'application (service worker) : onAvailable est * appelé quand une nouvelle version attend. */ readonly startUpdates: (onAvailable: () => void) => Updates; @@ -308,8 +341,11 @@ export function startApp(options: AppOptions): void { resumeOld: byId("resume-old"), home: byId("home"), tutorial: byId("tutorial"), + lessons: byId("lessons"), + lesson: byId("lesson"), menuHome: byId("menu-home"), menuTutorial: byId("menu-tutorial"), + menuLessons: byId("menu-lessons"), menuInstall: byId("menu-install"), menuUpdate: byId("menu-update"), installVeil: byId("install-veil"), @@ -407,6 +443,7 @@ export function startApp(options: AppOptions): void { ui.resumeOld.textContent = TEXTS.resumeOld; label(ui.menuHome, HOME_TEXTS.homeLink, "home"); label(ui.menuTutorial, HOME_TEXTS.tutorialMenu, "rules"); + label(ui.menuLessons, LESSON_TEXTS.menu, "lessons"); label(ui.menuInstall, HOME_TEXTS.installMenu, "install"); label(ui.menuUpdate, HOME_TEXTS.updateCheck, "replay"); ui.installTitle.textContent = HOME_TEXTS.installTitle; @@ -1722,6 +1759,10 @@ export function startApp(options: AppOptions): void { navigation.leave((r) => r.screen === "analysis"); } else if (route.screen === "tutorial" || route.screen === "tutorialEnd") { navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd"); + } else if (route.screen === "lesson" || route.screen === "lessonEnd") { + navigation.leave((r) => r.screen === "lesson" || r.screen === "lessonEnd"); + } else if (route.screen === "lessons") { + navigation.leave((r) => r.screen === "lessons"); } }, }; @@ -1827,6 +1868,7 @@ export function startApp(options: AppOptions): void { replacingLink(ui.menuHelp, navigation); replacingLink(ui.menuHome, navigation); replacingLink(ui.menuTutorial, navigation); + replacingLink(ui.menuLessons, navigation); ui.menuClose.onclick = () => closeOverlays(); ui.menuCopyLink.onclick = () => { const link = currentLink; @@ -1878,9 +1920,12 @@ export function startApp(options: AppOptions): void { const position = below.screen === "position"; const home = below.screen === "home"; const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd"; + const lessonList = below.screen === "lessons"; + const lesson = below.screen === "lesson" || below.screen === "lessonEnd"; const choosing = below.screen === "newGame"; const analysing = below.screen === "analysis"; - const page = help || position || home || tutorial || choosing || analysing; + const page = + help || position || home || tutorial || lessonList || lesson || choosing || analysing; const wasPage = ui.game.hidden; const wasScreen = ui.root.dataset.screen; ui.game.hidden = page; @@ -1888,13 +1933,18 @@ export function startApp(options: AppOptions): void { ui.position.hidden = !position; ui.home.hidden = !home; ui.tutorial.hidden = !tutorial; + ui.lessons.hidden = !lessonList; + ui.lesson.hidden = !lesson; ui.analysis.hidden = !analysing; const opening = choosing && ui.newGameSection.hidden; ui.newGameSection.hidden = !choosing; ui.root.classList.toggle("page-open", page); - // Le tutoriel, le premier lancement et l'écran « Nouvelle partie » - // ont leur propre en-tête. - ui.root.classList.toggle("bare", tutorial || choosing || (home && isFirstLaunch(settings))); + // Le tutoriel, les leçons, le premier lancement et l'écran « Nouvelle + // partie » ont leur propre en-tête. + ui.root.classList.toggle( + "bare", + tutorial || lesson || choosing || (home && isFirstLaunch(settings)), + ); if (opening) { newGameView.render(busy || game === null); newGameView.focus(); @@ -1910,6 +1960,21 @@ export function startApp(options: AppOptions): void { } else { tutorialView.hide(); } + if (lesson) { + // Une leçon qui n'existe pas : la liste des leçons. + if (below.lesson > LESSON_COUNT) { + navigation.replace(routeHash({ screen: "lessons" })); + return; + } + if (route.screen !== "install") { + void lessonView.show(below); + } + } else { + lessonView.hide(); + } + if (lessonList) { + renderLessons(); + } if (home) { homeProgress = null; } @@ -2064,8 +2129,26 @@ export function startApp(options: AppOptions): void { sides: sides ?? options.cachedBoard()?.sides ?? null, tutorialSeen: settings.tutorialSeen, progress: homeProgress, + lessons: + lessonTexts === null || lessonProgress === null + ? null + : { + next: nextLessonCard(lessonTexts, lessonProgress), + finished: finishedLessons(lessonProgress, lessonTexts.map(outlineOf)), + total: lessonTexts.length, + }, busy: busy || game === null, }); + if (lessonTexts === null || lessonProgress === null) { + lessonsReady().then( + () => { + if (navigation.route.screen === "home") { + renderHome(); + } + }, + () => undefined, + ); + } if (homeProgress === null && !progressLoading) { progressLoading = true; // La liste vient du stockage, ouvert avec le moteur. @@ -2088,17 +2171,158 @@ export function startApp(options: AppOptions): void { } } - const tutorialView = createTutorialView({ - section: ui.tutorial, - access: options.tutorial, - engineReady: options.engineReady, + const loadTutorial = cachedText(TUTORIAL_SOURCE, parseTutorial); + const tutorialCourse: Course = { + id: "tutorial", + load: () => loadTutorial(), + placeOf: tutorialPlaceOf, + routeOf: (_, place) => tutorialPlaceRoute(place), seen: () => settings.tutorialSeen, - visit: (place) => { + visit: (_, place) => { const seen = seenAfter(settings.tutorialSeen, place); if (seen !== settings.tutorialSeen || !settings.welcomed) { saveSettings({ tutorialSeen: seen, ...welcomed() }); } }, + solved: () => undefined, + quitHref: () => "#/", + firstBackHref: () => "#/accueil", + endHref: () => "#/", + texts: { + progress: HOME_TEXTS.tutorialProgress, + stepLink: HOME_TEXTS.tutorialStepLink, + quit: HOME_TEXTS.tutorialSkip, + quitLabel: HOME_TEXTS.tutorialSkipLabel, + overline: (_, n, total, practice) => HOME_TEXTS.tutorialOverline(n, total, practice), + endOverline: () => HOME_TEXTS.tutorialEndOverline, + endTitle: () => HOME_TEXTS.tutorialEndTitle, + loading: HOME_TEXTS.tutorialLoading, + failed: HOME_TEXTS.tutorialFailed, + }, + }; + const tutorialView = createTutorialView({ + section: ui.tutorial, + access: options.tutorial, + engineReady: options.engineReady, + course: tutorialCourse, + placed: () => haptic("checkerPlaced"), + reducedMotion, + landscape: () => layout() === "landscape", + replace: (hash) => navigation.replace(hash), + }); + + // ------------------------------------------------------------ Leçons + + // Les textes des leçons (docs/lecons/, copiés dans le site) et la + // progression enregistrée, lus une fois. + const lessonLoaders = Array.from({ length: LESSON_COUNT }, (_, i) => + cachedText(lessonSource(i + 1), (text) => parseLesson(i + 1, text)), + ); + let lessonTexts: Lesson[] | null = null; + let lessonProgress: LessonProgress | null = null; + const lessonProgressLoading = options.lessons + .load() + .catch(() => NO_PROGRESS) + .then((progress) => { + lessonProgress = progress; + return progress; + }); + /** Les leçons et la progression, lues (rejetée si un texte manque). */ + async function lessonsReady(): Promise<{ texts: Lesson[]; progress: LessonProgress }> { + const [texts, progress] = await Promise.all([ + Promise.all(lessonLoaders.map((load) => load())), + lessonProgressLoading, + ]); + lessonTexts = texts; + return { texts, progress }; + } + const progressNow = () => lessonProgress ?? NO_PROGRESS; + function updateLessons(progress: LessonProgress): void { + lessonProgress = progress; + options.lessons.save(progress); + } + const lessonOf = (route: Route) => + route.screen === "lesson" || route.screen === "lessonEnd" ? route.lesson : 1; + // Le texte que l'écran d'une leçon reçoit est une leçon (Lesson). + const lessonNumberOf = (text: Tutorial) => (text as Partial).number ?? 1; + const lessonTitleOf = (text: Tutorial) => (text as Partial).title ?? ""; + + const lessonsView = createLessonsView(ui.lessons); + function renderLessons(): void { + if (lessonTexts !== null && lessonProgress !== null) { + lessonsView.render(lessonCards(lessonTexts, lessonProgress)); + return; + } + lessonsView.render(null); + lessonsReady().then( + () => { + if (navigation.route.screen === "lessons") { + renderLessons(); + } + }, + () => { + if (navigation.route.screen === "lessons") { + lessonsView.render(null, true); + } + }, + ); + } + + const lessonCourse: Course = { + id: "lesson", + load: async (route) => { + await lessonProgressLoading; + const load = lessonLoaders[lessonOf(route) - 1]; + if (load === undefined) { + throw new Error(`leçon ${lessonOf(route)} absente`); + } + return load(); + }, + placeOf: lessonPlaceOf, + routeOf: (route, place) => lessonPlaceRoute(lessonOf(route), place), + seen: (route) => lessonRecord(progressNow(), lessonOf(route)).reached, + visit: (route, place) => { + const number = lessonOf(route); + const at = place === "end" ? LESSON_END : place.step; + const record = lessonRecord(progressNow(), number); + if (record.last !== at || record.reached < at) { + updateLessons(withVisit(progressNow(), number, at)); + } + if (!settings.welcomed) { + saveSettings(welcomed()); + } + }, + solved: (route, place) => { + if (place !== "end" && place.practice) { + updateLessons(withSolved(progressNow(), lessonOf(route), place.step)); + } + }, + quitHref: () => routeHash({ screen: "lessons" }), + firstBackHref: () => routeHash({ screen: "lessons" }), + endHref: (route) => { + const number = lessonOf(route); + return number < LESSON_COUNT + ? routeHash({ screen: "lesson", lesson: number + 1, step: 1, practice: false }) + : routeHash({ screen: "lessons" }); + }, + texts: { + progress: LESSON_TEXTS.progress, + stepLink: LESSON_TEXTS.stepLink, + quit: LESSON_TEXTS.quit, + quitLabel: LESSON_TEXTS.quitLabel, + overline: (text, n, total, practice) => + LESSON_TEXTS.overline(lessonNumberOf(text), n, total, practice), + endOverline: (text) => LESSON_TEXTS.endOverline(lessonNumberOf(text)), + endTitle: (text) => LESSON_TEXTS.endTitle(lessonTitleOf(text)), + loading: LESSON_TEXTS.loading, + failed: LESSON_TEXTS.failed, + }, + }; + const lessonView = createTutorialView({ + section: ui.lesson, + access: options.tutorial, + engineReady: options.engineReady, + course: lessonCourse, placed: () => haptic("checkerPlaced"), reducedMotion, landscape: () => layout() === "landscape", @@ -2369,6 +2593,9 @@ export function startApp(options: AppOptions): void { if (!ui.tutorial.hidden) { tutorialView.relayout(); } + if (!ui.lesson.hidden) { + lessonView.relayout(); + } }); }; window.addEventListener("resize", relayout); diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index 552ec67..f6e69d4 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -35,6 +35,7 @@ export const FEATURES = [ "docs/scenarios/revoir-ma-partie.feature", "docs/scenarios/demander-a-une-ia.feature", "docs/scenarios/apprendre-les-regles.feature", + "docs/scenarios/suivre-une-lecon.feature", "docs/scenarios/mes-parties.feature", "docs/scenarios/partager-une-position.feature", "docs/scenarios/regler-l-application.feature", diff --git a/app/src/ui/home-texts.ts b/app/src/ui/home-texts.ts index 2c31ac1..f8bf0d6 100644 --- a/app/src/ui/home-texts.ts +++ b/app/src/ui/home-texts.ts @@ -119,7 +119,7 @@ export const HOME_TEXTS = { aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).", aboutParagraphs: [ "Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.", - "Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.", + "Les textes du tutoriel, des leçons, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.", "L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.", "Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.", ], diff --git a/app/src/ui/home-view.ts b/app/src/ui/home-view.ts index 9b12663..c420426 100644 --- a/app/src/ui/home-view.ts +++ b/app/src/ui/home-view.ts @@ -1,13 +1,13 @@ // L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » / // « Je connais les règles » ; ensuite, la partie en cours, l'invitation à -// installer, les règles et la progression résumée +// installer, la prochaine leçon (1g), les règles et la progression résumée // (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »). // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). // -// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore : -// l'accueil n'affiche que ce qui existe. +// La progression détaillée (1h) n'existe pas encore : l'accueil n'affiche +// que ce qui existe. import type { SideBoards } from "../domain/game/game.js"; import { boardModel, OPENING_BOARDS } from "./board-model.js"; @@ -16,6 +16,9 @@ import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js"; import { type HomeModel, WELCOME_LINKS } from "./home-model.js"; import { HOME_TEXTS } from "./home-texts.js"; import { icon } from "./icons.js"; +import { LESSON_TEXTS } from "./lesson-texts.js"; +import type { LessonCardModel } from "./lessons-model.js"; +import { fillLessonCard, lessonAction } from "./lessons-view.js"; import { TUTORIAL_DONE } from "./settings.js"; import { TEXTS } from "./texts.js"; @@ -38,6 +41,13 @@ export interface HomeState { readonly tutorialSeen: number; /** Parties jouées et gagnées (null : pas encore lues). */ readonly progress: { readonly played: number; readonly won: number } | null; + /** Les leçons : la prochaine (null : toutes terminées), et combien sont + * terminées sur combien (null : pas encore lues). */ + readonly lessons: { + readonly next: LessonCardModel | null; + readonly finished: number; + readonly total: number; + } | null; /** Le moteur se charge, ou l'écran est occupé. */ readonly busy: boolean; } @@ -186,6 +196,23 @@ export function createHomeView(options: HomeViewOptions): HomeView { later.title = HOME_TEXTS.installLater; later.addEventListener("click", () => options.dismissInstall()); + // La prochaine leçon : une seule carte, « Continuer » en secondaire, et + // le lien vers toutes les leçons. + const lesson = element(main, "article", { id: "home-lesson", className: "card home-lesson" }); + element(lesson, "span", { className: "overline", text: LESSON_TEXTS.homeOverline }); + const lessonInfo = element(lesson, "div", { className: "home-lesson-info" }); + const lessonActions = element(lesson, "div", { className: "home-lesson-actions" }); + const lessonGo = element(lessonActions, "a", { + id: "home-lesson-go", + className: "button secondary", + }); + const lessonAll = element(lessonActions, "a", { + id: "home-lessons", + className: "home-lessons-link", + text: LESSON_TEXTS.allLessons, + }); + lessonAll.href = "#/lecons"; + const rules = element(main, "a", { id: "home-rules", className: "card home-rules" }); const rulesText = element(rules, "span", { className: "home-rules-text" }); element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle }); @@ -233,10 +260,40 @@ export function createHomeView(options: HomeViewOptions): HomeView { : seen >= TUTORIAL_DONE ? HOME_TEXTS.rulesAgain : HOME_TEXTS.rulesContinue(seen); - progress.textContent = - state.progress === null || state.progress.played === 0 - ? "" - : HOME_TEXTS.progress(state.progress.played, state.progress.won); + // La carte de la leçon, une fois les leçons lues. + lesson.hidden = state.lessons === null; + if (state.lessons !== null) { + const card = state.lessons.next; + if (card === null) { + lessonInfo.replaceChildren(); + element(lessonInfo, "strong", { + className: "lesson-card-title", + text: LESSON_TEXTS.homeAllDone, + }); + element(lessonInfo, "span", { + className: "muted", + text: LESSON_TEXTS.homeAllDoneDetail, + }); + lessonGo.hidden = true; + } else { + fillLessonCard(lessonInfo, card); + lessonGo.hidden = false; + lessonGo.textContent = lessonAction(card); + lessonGo.href = card.href; + lessonGo.setAttribute( + "aria-label", + `${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`, + ); + } + } + const parts: string[] = []; + if (state.lessons !== null) { + parts.push(LESSON_TEXTS.homeSummary(state.lessons.finished, state.lessons.total)); + } + if (state.progress !== null && state.progress.played > 0) { + parts.push(HOME_TEXTS.progress(state.progress.played, state.progress.won)); + } + progress.textContent = parts.join(" · "); }, }; } diff --git a/app/src/ui/icons.ts b/app/src/ui/icons.ts index b65d151..aa11539 100644 --- a/app/src/ui/icons.ts +++ b/app/src/ui/icons.ts @@ -25,6 +25,7 @@ export const ICON_PATHS = { home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6", rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z", install: "M12 4v11M7 10l5 5 5-5M5 20h14", + lessons: "M3 9l9-5 9 5-9 5zM7 11.2V16c0 1.6 2.2 3 5 3s5-1.4 5-3v-4.8M21 9v6", verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6", verdictDoubtful: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1", diff --git a/app/src/ui/lesson-texts.ts b/app/src/ui/lesson-texts.ts new file mode 100644 index 0000000..e4cf4b6 --- /dev/null +++ b/app/src/ui/lesson-texts.ts @@ -0,0 +1,57 @@ +// Les textes de la liste des leçons, de leurs écrans et de la carte +// « prochaine leçon » de l'accueil (ADR 0007, point 5 ; +// docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »). Le texte des +// leçons elles-mêmes n'est pas ici : il vit dans docs/lecons/. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { LessonState } from "../domain/learning/lessons.js"; + +function plural(n: number, one: string, many: string): string { + return `${n} ${n > 1 ? many : one}`; +} + +export const LESSON_TEXTS = { + // Liste + menu: "Les leçons", + listTitle: "Les leçons", + listLead: + "Cinq leçons courtes, plateau à l'appui, avec des exercices où vous jouez le coup du maître. L'ordre est conseillé, jamais imposé.", + rulesReminder: "Les règles du jeu", + rulesReview: "revoir", + rulesReviewLabel: "Revoir les règles du jeu (le tutoriel)", + number: (n: number) => `Leçon ${n}`, + listLoading: "Chargement des leçons…", + listFailed: "Les leçons n'ont pas pu se charger.", + state: (state: LessonState) => + state.kind === "done" + ? "Terminée" + : state.kind === "toStart" + ? `À commencer · ${plural(state.total, "exercice", "exercices")}` + : `${plural(state.solved, "exercice", "exercices")} sur ${state.total}`, + continue: "Continuer", + start: "Commencer", + review: "Revoir", + openLabel: (n: number, title: string) => `Leçon ${n} : ${title}`, + + // Écrans d'une leçon + progress: "Étapes de la leçon", + stepLink: (n: number, title: string) => `Étape ${n} : ${title}`, + quit: "Quitter", + quitLabel: "Quitter la leçon", + overline: (lesson: number, n: number, total: number, practice: boolean) => + `Leçon ${lesson} · ${n} sur ${total}${practice ? " · à vous" : ""}`, + endOverline: (lesson: number) => `Leçon ${lesson} · fin`, + endTitle: (title: string) => title, + loading: "Chargement de la leçon…", + failed: "La leçon n'a pas pu se charger.", + + // Accueil + homeOverline: "Prochaine leçon", + homeAllDone: "Toutes les leçons sont terminées", + homeAllDoneDetail: "Vous pouvez les revoir quand vous voulez.", + allLessons: "Toutes les leçons", + homeSummary: (finished: number, total: number) => + `${plural(finished, "leçon terminée", "leçons terminées")} sur ${total}`, +} as const; diff --git a/app/src/ui/lessons-model.ts b/app/src/ui/lessons-model.ts new file mode 100644 index 0000000..76e0cd1 --- /dev/null +++ b/app/src/ui/lessons-model.ts @@ -0,0 +1,66 @@ +// Ce que montrent la liste des leçons et la carte « prochaine leçon » de +// l'accueil : fonctions pures, testées sous Node +// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { + type LessonProgress, + type LessonState, + lessonRecord, + lessonState, + nextLesson, + resumeStep, +} from "../domain/learning/lessons.js"; +import { type Lesson, outlineOf } from "./lesson-text.js"; +import { routeHash } from "./router.js"; + +/** Une leçon dans la liste, ou sur l'accueil. */ +export interface LessonCardModel { + readonly number: number; + readonly title: string; + readonly state: LessonState; + /** Les exercices, dans l'ordre : réussi ou non (barre de progression). */ + readonly exercises: readonly boolean[]; + /** Où mène la carte : là où reprendre la leçon. */ + readonly href: string; + /** La leçon proposée ensuite : carte bordée de brique, seule action + * principale de l'écran. */ + readonly current: boolean; +} + +/** L'adresse où reprendre la leçon. */ +export function resumeHref(progress: LessonProgress, lesson: Lesson): string { + const step = resumeStep(progress, outlineOf(lesson)); + return routeHash({ screen: "lesson", lesson: lesson.number, step, practice: false }); +} + +/** Les cartes de la liste des leçons, dans l'ordre. */ +export function lessonCards( + lessons: readonly Lesson[], + progress: LessonProgress, +): LessonCardModel[] { + const outlines = lessons.map(outlineOf); + const next = nextLesson(progress, outlines)?.number ?? null; + return lessons.map((lesson, i) => { + const outline = outlines[i] ?? outlineOf(lesson); + const solved = lessonRecord(progress, lesson.number).solved; + return { + number: lesson.number, + title: lesson.title, + state: lessonState(progress, outline), + exercises: outline.exercises.map((step) => solved.includes(step)), + href: resumeHref(progress, lesson), + current: lesson.number === next, + }; + }); +} + +/** La carte « prochaine leçon » de l'accueil (null : toutes terminées). */ +export function nextLessonCard( + lessons: readonly Lesson[], + progress: LessonProgress, +): LessonCardModel | null { + return lessonCards(lessons, progress).find((card) => card.current) ?? null; +} diff --git a/app/src/ui/lessons-view.ts b/app/src/ui/lessons-view.ts new file mode 100644 index 0000000..47bd3cf --- /dev/null +++ b/app/src/ui/lessons-view.ts @@ -0,0 +1,134 @@ +// La liste des leçons (#/lecons) et la carte d'une leçon, reprise par la +// carte « prochaine leçon » de l'accueil +// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { LESSON_TEXTS } from "./lesson-texts.js"; +import type { LessonCardModel } from "./lessons-model.js"; + +function element( + parent: HTMLElement, + tag: K, + options: { id?: string; className?: string; text?: string } = {}, +): HTMLElementTagNameMap[K] { + const child = document.createElement(tag); + if (options.id !== undefined) { + child.id = options.id; + } + if (options.className !== undefined) { + child.className = options.className; + } + if (options.text !== undefined) { + child.textContent = options.text; + } + parent.append(child); + return child; +} + +/** La pastille ronde au numéro de la leçon : verte une fois terminée, + * brique pour la leçon proposée, au contour sinon. */ +function badge(parent: HTMLElement, card: LessonCardModel): void { + const kind = card.state.kind === "done" ? "done" : card.current ? "current" : "todo"; + const span = element(parent, "span", { + className: `lesson-badge ${kind}`, + text: String(card.number), + }); + span.setAttribute("aria-hidden", "true"); +} + +/** La barre de progression : un segment par exercice, en brique une fois + * réussi. */ +function progressBar(parent: HTMLElement, card: LessonCardModel): void { + const bar = element(parent, "span", { className: "lesson-progress" }); + bar.setAttribute("aria-hidden", "true"); + for (const done of card.exercises) { + element(bar, "span", { className: done ? "segment done" : "segment" }); + } +} + +/** Le contenu d'une carte de leçon : pastille, numéro, titre, état et + * progression. */ +export function fillLessonCard(target: HTMLElement, card: LessonCardModel): void { + target.replaceChildren(); + const top = element(target, "span", { className: "lesson-card-top" }); + badge(top, card); + const body = element(top, "span", { className: "lesson-card-body" }); + element(body, "span", { className: "overline", text: LESSON_TEXTS.number(card.number) }); + element(body, "strong", { className: "lesson-card-title", text: card.title }); + element(body, "span", { + className: `lesson-card-state ${card.state.kind}`, + text: LESSON_TEXTS.state(card.state), + }); + if (card.state.kind !== "toStart" && card.state.total > 0) { + progressBar(body, card); + } +} + +/** Le libellé de l'action d'une leçon. */ +export function lessonAction(card: LessonCardModel): string { + return card.state.kind === "toStart" + ? LESSON_TEXTS.start + : card.state.kind === "done" + ? LESSON_TEXTS.review + : LESSON_TEXTS.continue; +} + +export interface LessonsView { + /** Les cartes (null : les leçons se chargent ; erreur : échec). */ + render(cards: readonly LessonCardModel[] | null, failed?: boolean): void; +} + +/** Crée la liste des leçons dans section (vide dans index.html). */ +export function createLessonsView(section: HTMLElement): LessonsView { + section.replaceChildren(); + const head = element(section, "div", { className: "lessons-intro" }); + element(head, "h2", { text: LESSON_TEXTS.listTitle }); + element(head, "p", { className: "muted", text: LESSON_TEXTS.listLead }); + const rules = element(section, "p", { className: "lessons-rules caption" }); + rules.append(`${LESSON_TEXTS.rulesReminder} · `); + const review = element(rules, "a", { id: "lessons-rules", text: LESSON_TEXTS.rulesReview }); + review.href = "#/tutoriel/1"; + review.setAttribute("aria-label", LESSON_TEXTS.rulesReviewLabel); + const status = element(section, "p", { id: "lessons-status", className: "muted" }); + status.setAttribute("role", "status"); + const list = element(section, "ol", { id: "lesson-list", className: "lesson-list" }); + + return { + render(cards, failed = false) { + status.textContent = + cards !== null ? "" : failed ? LESSON_TEXTS.listFailed : LESSON_TEXTS.listLoading; + list.replaceChildren(); + for (const card of cards ?? []) { + const item = element(list, "li"); + // La leçon proposée porte la seule action principale ; les + // autres cartes sont des liens, toutes restent ouvertes. + if (card.current) { + const article = element(item, "article", { + id: `lesson-card-${card.number}`, + className: "card lesson-card current", + }); + const info = element(article, "div"); + fillLessonCard(info, card); + const action = element(article, "a", { + className: "button lesson-card-action", + text: lessonAction(card), + }); + action.href = card.href; + action.setAttribute( + "aria-label", + `${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`, + ); + } else { + const link = element(item, "a", { + id: `lesson-card-${card.number}`, + className: "card lesson-card", + }); + link.href = card.href; + fillLessonCard(link, card); + } + } + }, + }; +} diff --git a/app/src/ui/router.ts b/app/src/ui/router.ts index 584f6a2..f196109 100644 --- a/app/src/ui/router.ts +++ b/app/src/ui/router.ts @@ -24,6 +24,19 @@ export type Route = | { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean } /** #/tutoriel/fin : la fin du tutoriel. */ | { readonly screen: "tutorialEnd" } + /** #/lecons : la liste des leçons (étape 1g). */ + | { readonly screen: "lessons" } + /** #/lecons/ (son premier écran), une étape de la leçon n : + * #/lecons//<étape>, et son exercice « à vous » : + * #/lecons//<étape>/a-vous. */ + | { + readonly screen: "lesson"; + readonly lesson: number; + readonly step: number | null; + readonly practice: boolean; + } + /** #/lecons//fin : la fin de la leçon n. */ + | { readonly screen: "lessonEnd"; readonly lesson: number } /** #/installer : la marche à suivre pour installer l'application. */ | { readonly screen: "install" } /** #/parties, et une partie : #/parties/. */ @@ -91,6 +104,9 @@ export function parseRoute(hash: string): Route { if (head === "tutoriel") { return tutorialRoute(rest) ?? unknown; } + if (head === "lecons") { + return lessonRoute(rest) ?? unknown; + } if (head === "parties" && rest.length > 1) { return gameReviewRoute(rest) ?? unknown; } @@ -194,6 +210,29 @@ function tutorialRoute(rest: readonly string[]): Route | null { return { screen: "tutorial", step: Number(name), practice: part === "a-vous" }; } +/** #/lecons, #/lecons/, #/lecons//<étape>, #/lecons//<étape>/a-vous, + * #/lecons//fin. */ +function lessonRoute(rest: readonly string[]): Route | null { + const [number, name, part, ...more] = rest; + if (number === undefined) { + return { screen: "lessons" }; + } + if (!/^[1-9]$/.test(number) || more.length > 0) { + return null; + } + const lesson = Number(number); + if (name === undefined) { + return { screen: "lesson", lesson, step: null, practice: false }; + } + if (name === "fin") { + return part === undefined ? { screen: "lessonEnd", lesson } : null; + } + if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) { + return null; + } + return { screen: "lesson", lesson, step: Number(name), practice: part === "a-vous" }; +} + /** * #/position/... : le Position ID (14 caractères), puis, après une barre, le * Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une @@ -246,6 +285,14 @@ function screenHash(route: Route): string { : `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`; case "tutorialEnd": return "#/tutoriel/fin"; + case "lessons": + return "#/lecons"; + case "lesson": + return route.step === null + ? `#/lecons/${route.lesson}` + : `#/lecons/${route.lesson}/${route.step}${route.practice ? "/a-vous" : ""}`; + case "lessonEnd": + return `#/lecons/${route.lesson}/fin`; case "install": return "#/installer"; case "games": diff --git a/app/src/ui/tutorial-nav.ts b/app/src/ui/tutorial-nav.ts index 04784e8..d23aaca 100644 --- a/app/src/ui/tutorial-nav.ts +++ b/app/src/ui/tutorial-nav.ts @@ -19,14 +19,19 @@ export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace { if (route.screen === "tutorialEnd") { return "end"; } - if (route.screen !== "tutorial" || route.step === null) { - return { step: 1, practice: false }; - } - const step = tutorial.steps[route.step - 1]; + return route.screen === "tutorial" + ? existingPlace(tutorial, route.step, route.practice) + : { step: 1, practice: false }; +} + +/** Le même écran qu'une adresse d'étape, s'il existe ; sinon la première + * étape (étape absente), ou l'explication (« à vous » absent). */ +function existingPlace(tutorial: Tutorial, at: number | null, practice: boolean): TutorialPlace { + const step = at === null ? undefined : tutorial.steps[at - 1]; if (step === undefined) { return { step: 1, practice: false }; } - return { step: step.number, practice: route.practice && step.practice !== null }; + return { step: step.number, practice: practice && step.practice !== null }; } /** L'adresse d'un écran du tutoriel. */ @@ -36,6 +41,24 @@ export function placeRoute(place: TutorialPlace): Route { : { screen: "tutorial", step: place.step, practice: place.practice }; } +/** L'écran d'une leçon que désigne une adresse (#/lecons//...), ramené à + * un écran qui existe, comme placeOf pour le tutoriel. */ +export function lessonPlaceOf(lesson: Tutorial, route: Route): TutorialPlace { + if (route.screen === "lessonEnd") { + return "end"; + } + return route.screen === "lesson" + ? existingPlace(lesson, route.step, route.practice) + : { step: 1, practice: false }; +} + +/** L'adresse d'un écran de la leçon number. */ +export function lessonPlaceRoute(number: number, place: TutorialPlace): Route { + return place === "end" + ? { screen: "lessonEnd", lesson: number } + : { screen: "lesson", lesson: number, step: place.step, practice: place.practice }; +} + /** L'écran suivant (la fin après la dernière étape). */ export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace { if (place === "end") { diff --git a/app/src/ui/tutorial-view.ts b/app/src/ui/tutorial-view.ts index 425ec3a..73a15ec 100644 --- a/app/src/ui/tutorial-view.ts +++ b/app/src/ui/tutorial-view.ts @@ -2,11 +2,14 @@ // #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte, // et les écrans « à vous » où le joueur fait le geste sur le vrai plateau // (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »). +// Les leçons (#/lecons//..., étape 1g) reprennent le même écran, avec +// leur texte et leurs adresses (Course). // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). // -// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux, +// Le texte vient de docs/tutoriel/regles.md ou de docs/lecons/ (copies du +// site) ; les plateaux, // les coups illustrés et le jugement des gestes viennent du domaine, branché // sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est // écrite ici. @@ -39,20 +42,8 @@ import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geo import { HOME_TEXTS } from "./home-texts.js"; import { icon } from "./icons.js"; import { type Route, routeHash } from "./router.js"; -import { - nextPlace, - placeOf, - placeRoute, - previousPlace, - progress, - type TutorialPlace, -} from "./tutorial-nav.js"; -import { - type Paragraph, - parseTutorial, - type Tutorial, - type TutorialScreen, -} from "./tutorial-text.js"; +import { nextPlace, previousPlace, progress, type TutorialPlace } from "./tutorial-nav.js"; +import type { Paragraph, Tutorial, TutorialScreen } from "./tutorial-text.js"; /** Le coup illustré se joue tout seul après une seconde. */ const AUTOPLAY_MS = 1000; @@ -61,7 +52,32 @@ const PAUSE_MS = 120; /** Un pion refusé reste un instant à sa place avant de revenir. */ const REFUSED_MS = 450; -const SOURCE = "docs/tutoriel/regles.md"; +/** Le texte du tutoriel, copié dans le site (make site). */ +export const TUTORIAL_SOURCE = "docs/tutoriel/regles.md"; + +/** + * Un texte du site, lu une fois puis gardé : la fonction renvoyée le + * télécharge à son premier appel et le relit avec parse. Un échec n'est + * pas gardé : l'appel suivant réessaie. + */ +export function cachedText(source: string, parse: (text: string) => T): () => Promise { + let loaded: Promise | null = null; + return () => { + loaded ??= fetch(source) + .then((response) => { + if (!response.ok) { + throw new Error(`${source} : ${response.status}`); + } + return response.text(); + }) + .then(parse) + .catch((error: unknown) => { + loaded = null; + throw error; + }); + return loaded; + }; +} /** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */ export interface PracticeSession { @@ -87,14 +103,53 @@ export interface TutorialAccess { shouldTake(position: string): Promise; } +/** + * Un parcours d'écrans courts : le tutoriel des règles, ou une leçon (étape + * 1g), qui en reprennent la forme (charte, « 1g. Leçons »). Ce qui les + * distingue : leur texte, leurs adresses, leurs libellés, et ce que retient + * la progression. + */ +export interface Course { + /** Préfixe des identifiants des éléments de l'écran (« tutorial », + * « lesson ») : deux parcours peuvent coexister dans la page. */ + readonly id: string; + /** Le texte du parcours que désigne l'adresse. */ + load(route: Route): Promise; + /** L'écran que désigne l'adresse, ramené à un écran qui existe. */ + placeOf(text: Tutorial, route: Route): TutorialPlace; + /** L'adresse d'un écran du même parcours que route. */ + routeOf(route: Route, place: TutorialPlace): Route; + /** La dernière étape déjà vue : la barre de progression n'en mène pas + * plus loin. */ + seen(route: Route): number; + /** L'arrivée sur un écran. */ + visit(route: Route, place: TutorialPlace): void; + /** Un écran « à vous » réussi. */ + solved(route: Route, place: TutorialPlace): void; + /** « Quitter » : où il mène. */ + quitHref(route: Route): string; + /** « Retour » sur le premier écran. */ + firstBackHref(route: Route): string; + /** L'action de l'écran de fin. */ + endHref(route: Route): string; + readonly texts: { + readonly progress: string; + readonly stepLink: (n: number, title: string) => string; + readonly quit: string; + readonly quitLabel: string; + readonly overline: (text: Tutorial, n: number, total: number, practice: boolean) => string; + readonly endOverline: (text: Tutorial) => string; + readonly endTitle: (text: Tutorial) => string; + readonly loading: string; + readonly failed: string; + }; +} + export interface TutorialViewOptions { readonly section: HTMLElement; readonly access: TutorialAccess; readonly engineReady: Promise; - /** La dernière étape vue, et son enregistrement à l'arrivée sur un - * écran. */ - readonly seen: () => number; - readonly visit: (place: TutorialPlace) => void; + readonly course: Course; /** Un geste juste : la vibration « pion posé ». */ readonly placed: () => void; readonly reducedMotion: MediaQueryList; @@ -221,24 +276,28 @@ function drawDirections(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): voi /** Crée l'écran du tutoriel dans section (vide dans index.html). */ export function createTutorialView(options: TutorialViewOptions): TutorialView { - const { section, access } = options; + const { section, access, course } = options; + const idOf = (name: string) => `${course.id}-${name}`; section.replaceChildren(); const head = element(section, "header", { className: "tutorial-head" }); - const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" }); - bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress); - const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" }); + const bar = element(head, "nav", { id: idOf("progress"), className: "tutorial-progress" }); + bar.setAttribute("aria-label", course.texts.progress); + const skip = element(head, "a", { id: idOf("skip"), className: "tutorial-skip" }); skip.href = "#/"; - skip.textContent = HOME_TEXTS.tutorialSkip; - skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel); - const overline = element(section, "p", { id: "tutorial-overline", className: "overline" }); - const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" }); + skip.textContent = course.texts.quit; + skip.setAttribute("aria-label", course.texts.quitLabel); + const overline = element(section, "p", { id: idOf("overline"), className: "overline" }); + const title = element(section, "h2", { id: idOf("title"), className: "tutorial-title" }); const boardBox = element(section, "div", { className: "tutorial-board-box" }); const svg = document.createElementNS(SVG_NS, "svg"); - svg.id = "tutorial-board"; + svg.id = idOf("board"); svg.setAttribute("class", "board tutorial-board"); svg.setAttribute("role", "img"); boardBox.append(svg); - const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" }); + const replay = element(boardBox, "button", { + id: idOf("replay"), + className: "icon-button tutorial-replay", + }); replay.type = "button"; replay.append(icon("replay")); replay.setAttribute("aria-label", HOME_TEXTS.replayLabel); @@ -246,39 +305,43 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { // Le texte, la consigne « à vous » et la réponse : en paysage, la seule // partie qui défile si l'écran est trop bas (app.css). const body = element(section, "div", { className: "tutorial-body" }); - const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" }); - const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" }); + const text = element(body, "div", { id: idOf("text"), className: "tutorial-text" }); + const task = element(body, "p", { id: idOf("task"), className: "tutorial-task" }); const feedback = element(body, "p", { - id: "tutorial-feedback", + id: idOf("feedback"), className: "tutorial-feedback", }); feedback.setAttribute("role", "status"); - const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" }); + const cube = element(section, "div", { id: idOf("cube"), className: "tutorial-cube" }); const drop = element(cube, "button", { - id: "tutorial-drop", + id: idOf("drop"), className: "secondary", text: HOME_TEXTS.drop, }); drop.type = "button"; const take = element(cube, "button", { - id: "tutorial-take", + id: idOf("take"), className: "secondary", text: HOME_TEXTS.take, }); take.type = "button"; const nav = element(section, "div", { className: "tutorial-nav" }); const back = element(nav, "a", { - id: "tutorial-back", + id: idOf("back"), className: "button secondary", text: HOME_TEXTS.back, }); const next = element(nav, "a", { - id: "tutorial-next", + id: idOf("next"), className: "button", text: HOME_TEXTS.next, }); - let tutorial: Promise | null = null; + // Le dernier texte lu : tant qu'il n'y en a pas, l'écran dit qu'il se + // charge. + let lastText: Tutorial | null = null; + // L'adresse affichée. + let shownRoute: Route | null = null; // Chaque affichage a son numéro : un affichage dépassé ne touche plus // à l'écran (le joueur est déjà ailleurs). let shown = 0; @@ -302,22 +365,6 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { let selected: Place | null = null; let solved = false; - const load = () => { - tutorial ??= fetch(SOURCE) - .then((response) => { - if (!response.ok) { - throw new Error(`${SOURCE} : ${response.status}`); - } - return response.text(); - }) - .then(parseTutorial) - .catch((error: unknown) => { - tutorial = null; - throw error; - }); - return tutorial; - }; - /** En paysage, le plateau occupe toute la hauteur, à gauche de la * colonne du texte (app.css), avec la géométrie du jeu en paysage. */ function geometry(): Geometry { @@ -456,6 +503,9 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { take.disabled = true; drop.disabled = true; options.placed(); + if (shownRoute !== null && place !== null) { + course.solved(shownRoute, place); + } } async function playHalfMove(halfMove: HalfMove): Promise { @@ -550,17 +600,21 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { take.addEventListener("click", () => void answer(true)); drop.addEventListener("click", () => void answer(false)); - function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void { + function renderProgress(route: Route, at: TutorialPlace, loaded: Tutorial): void { + const steps = loaded.steps; + const total = steps.length; bar.replaceChildren(); - for (const segment of progress(total, at, options.seen())) { + for (const segment of progress(total, at, course.seen(route))) { const stepTitle = steps[segment.step - 1]?.title ?? ""; - const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle); + const label = course.texts.stepLink(segment.step, stepTitle); const item = segment.link ? document.createElement("a") : document.createElement("span"); item.className = `segment${segment.done ? " done" : ""}`; if (item instanceof HTMLAnchorElement) { - item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false }); + item.href = routeHash( + course.routeOf(route, { step: segment.step, practice: false }), + ); item.setAttribute("aria-label", label); } else { item.setAttribute("aria-hidden", "true"); @@ -573,8 +627,8 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { bar.setAttribute( "aria-description", at === "end" - ? HOME_TEXTS.tutorialEndOverline - : HOME_TEXTS.tutorialOverline(at.step, total, at.practice), + ? course.texts.endOverline(loaded) + : course.texts.overline(loaded, at.step, total, at.practice), ); } @@ -603,42 +657,48 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { drop.disabled = false; let loaded: Tutorial; try { - if (tutorial === null) { - title.textContent = HOME_TEXTS.tutorialLoading; + if (lastText === null) { + title.textContent = course.texts.loading; text.replaceChildren(); svg.replaceChildren(); } - loaded = await load(); + loaded = await course.load(route); } catch { if (id === shown) { - title.textContent = HOME_TEXTS.tutorialFailed; + title.textContent = course.texts.failed; } return; } if (id !== shown) { return; } - const at = placeOf(loaded, route); - const wanted = routeHash(placeRoute(at)); + lastText = loaded; + const at = course.placeOf(loaded, route); + const wanted = routeHash(course.routeOf(route, at)); if (routeHash(route) !== wanted) { options.replace(wanted); return; } place = at; - options.visit(at); + shownRoute = route; + course.visit(route, at); const total = loaded.steps.length; - renderProgress(total, at, loaded.steps); + renderProgress(route, at, loaded); + skip.href = course.quitHref(route); const before = previousPlace(loaded, at); - back.href = before === null ? "#/accueil" : routeHash(placeRoute(before)); + back.href = + before === null + ? course.firstBackHref(route) + : routeHash(course.routeOf(route, before)); if (at === "end") { - overline.textContent = HOME_TEXTS.tutorialEndOverline; - title.textContent = HOME_TEXTS.tutorialEndTitle; + overline.textContent = course.texts.endOverline(loaded); + title.textContent = course.texts.endTitle(loaded); text.replaceChildren(); for (const paragraph of loaded.end.paragraphs) { fillParagraph(element(text, "p"), paragraph); } next.textContent = loaded.end.action; - next.href = "#/"; + next.href = course.endHref(route); next.classList.remove("secondary"); screen = loaded.steps[0]?.explain ?? null; } else { @@ -648,7 +708,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { return; } screen = { ...current, path: current.path && !at.practice }; - overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice); + overline.textContent = course.texts.overline(loaded, at.step, total, at.practice); title.textContent = step.title; text.replaceChildren(); const paragraphs = at.practice ? [] : current.paragraphs; @@ -669,7 +729,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { } const after = nextPlace(loaded, at); next.textContent = HOME_TEXTS.next; - next.href = routeHash(placeRoute(after)); + next.href = routeHash(course.routeOf(route, after)); next.classList.toggle("secondary", at.practice); } const currentScreen = screen; diff --git a/app/tests/lessons.test.ts b/app/tests/lessons.test.ts new file mode 100644 index 0000000..c1d31cd --- /dev/null +++ b/app/tests/lessons.test.ts @@ -0,0 +1,438 @@ +// Tests des leçons (étape 1g) : le texte des cinq leçons (docs/lecons/) se +// lit ; chaque position est valide pour le vrai moteur ; chaque coup attendu +// d'un exercice est le meilleur coup du moteur à 2 plis, avec plus de 0,04 +// d'avance sur le deuxième, et seul accepté ; chaque coup illustré est aussi +// le meilleur coup ; chaque compte de points cité est celui du moteur. Puis +// la progression : sa lecture (formats durables, ADR 0007), son +// enregistrement, les états affichés et la leçon proposée. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { test } from "node:test"; +import type { Engine, EngineCube } from "../src/domain/game/engine.js"; +import type { Roll } from "../src/domain/game/roll.js"; +import { + finishedLessons, + LESSON_END, + LESSON_PROGRESS_VERSION, + type LessonOutline, + lessonRecord, + lessonState, + NO_PROGRESS, + nextLesson, + readLessonProgress, + resumeStep, + withSolved, + withVisit, +} from "../src/domain/learning/lessons.js"; +import { MovePractice, tutorialBoard } from "../src/domain/learning/tutorial.js"; +import { createKeyValueBackend } from "../src/storage/key-value-backend.js"; +import { createLessonStore } from "../src/storage/lesson-store.js"; +import { memoryStorage } from "../src/storage/open-library.js"; +import { LESSON_COUNT, type Lesson, outlineOf, parseLesson } from "../src/ui/lesson-text.js"; +import { LESSON_TEXTS } from "../src/ui/lesson-texts.js"; +import { lessonCards, nextLessonCard } from "../src/ui/lessons-model.js"; +import { lessonPlaceOf, lessonPlaceRoute } from "../src/ui/tutorial-nav.js"; +import type { TutorialScreen } from "../src/ui/tutorial-text.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +const root = new URL("../../../", import.meta.url); +const lessons: Lesson[] = Array.from({ length: LESSON_COUNT }, (_, i) => + parseLesson(i + 1, readFileSync(new URL(`docs/lecons/lecon-${i + 1}.md`, root), "utf8")), +); + +/** Niveau d'analyse des réponses attendues (décision de Pierre). */ +const PLY = 2; +/** Avance minimale du coup attendu sur le deuxième : un exercice n'a + * qu'une bonne réponse nette. */ +const MIN_GAP = 0.04; +const CENTERED: EngineCube = { value: 1, owner: "centered" }; + +/** Les écrans d'une leçon, avec leur nom pour les messages. */ +function screens(lesson: Lesson): [string, TutorialScreen][] { + return lesson.steps.flatMap((s) => [ + [`leçon ${lesson.number}, étape ${s.number}`, s.explain] as [string, TutorialScreen], + ...(s.practice === null + ? [] + : [ + [`leçon ${lesson.number}, étape ${s.number}, à vous`, s.practice] as [ + string, + TutorialScreen, + ], + ]), + ]); +} +const allScreens = () => lessons.flatMap(screens); + +/** Le classement du moteur à 2 plis pour un coup : son rang, et l'écart + * avec le meilleur coup suivant (positif : le coup a cette avance). */ +async function rankOf( + engine: Engine, + position: string, + roll: Roll, + notation: string, +): Promise<{ rank: number; best: string; gap: number; second: string }> { + const legal = await engine.legalMoves(position, roll); + const move = legal.find((m) => m.notation === notation); + assert.ok(move !== undefined, `${notation} n'est pas un coup légal de ${roll.join("-")}`); + const analysis = await engine.analyseMove(position, CENTERED, roll, move.position, PLY); + assert.ok(analysis !== null, `${notation} : aucune analyse`); + const second = analysis.ranking.find((m) => m.position !== analysis.best.position); + assert.ok(second !== undefined, `${notation} : un seul coup possible, rien à choisir`); + // Le deuxième coup analysé à son tour : l'analyse le réévalue au même + // niveau que le meilleur, ce qui donne l'écart exact. + const other = await engine.analyseMove(position, CENTERED, roll, second.position, PLY); + assert.ok(other !== null); + return { + rank: analysis.rank, + best: analysis.best.notation, + gap: -other.equityLoss, + second: second.notation, + }; +} + +test("cinq leçons, de deux à quatre exercices chacune, avec un titre et une fin", () => { + assert.equal(lessons.length, 5); + assert.deepEqual( + readdirSync(new URL("docs/lecons/", root)) + .filter((f) => f.startsWith("lecon-")) + .sort(), + ["lecon-1.md", "lecon-2.md", "lecon-3.md", "lecon-4.md", "lecon-5.md"], + ); + assert.deepEqual( + lessons.map((l) => l.title), + [ + "Les ouvertures", + "Construire ses cases", + "Frapper ou ne pas frapper", + "La course et le compte de points", + "Sortir sans se faire frapper", + ], + ); + for (const lesson of lessons) { + const exercises = outlineOf(lesson).exercises.length; + assert.ok( + exercises >= 2 && exercises <= 4, + `leçon ${lesson.number} : ${exercises} exercices`, + ); + assert.ok(lesson.end.action !== "" && lesson.end.paragraphs.length > 0); + for (const step of lesson.steps) { + assert.ok( + step.explain.paragraphs.length > 0, + `leçon ${lesson.number}, étape ${step.number} sans texte`, + ); + const practice = step.practice; + if (practice !== null) { + assert.ok( + practice.paragraphs.length > 0, + `leçon ${lesson.number} : consigne absente`, + ); + assert.ok(practice.right.length > 0 && practice.wrong.length > 0); + assert.equal(practice.expected.length, 1, "un seul coup attendu par exercice"); + assert.ok(practice.roll !== null); + } + } + } +}); + +test("le texte des leçons garde le ton du maître et les mots du glossaire", () => { + for (const lesson of lessons) { + const text = readFileSync(new URL(`docs/lecons/lecon-${lesson.number}.md`, root), "utf8"); + // Pas de point d'exclamation, pas de « camp » (remplacé par les + // jans), pas de reproche. + assert.ok(!text.includes("!"), `leçon ${lesson.number} : point d'exclamation`); + assert.ok(!/\bcamp\b/i.test(text), `leçon ${lesson.number} : « camp »`); + assert.ok( + !/vous auriez dû|il fallait|erreur/i.test(text), + `leçon ${lesson.number} : reproche`, + ); + assert.ok(text.includes("CC BY-SA 4.0"), `leçon ${lesson.number} : licence`); + const inlines = screens(lesson).flatMap(([, s]) => [ + ...s.paragraphs.flat(), + ...s.right, + ...s.wrong, + ]); + for (const term of inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]))) { + assert.ok( + existsSync(new URL(`docs/glossaire/${term}.md`, root)), + `fiche ${term}.md absente`, + ); + } + } + const terms = new Set( + lessons + .flatMap((l) => screens(l).flatMap(([, s]) => s.paragraphs.flat())) + .flatMap((i) => (i.term === undefined ? [] : [i.term])), + ); + for (const term of ["jan-interieur", "jan-exterieur", "point", "blot", "compte-de-points"]) { + assert.ok(terms.has(term), `aucun lien vers ${term}.md`); + } +}); + +test("chaque position des leçons est valide, et ses comptes de points sont ceux du moteur", async () => { + const engine = await loadNodeEngine(); + let counts = 0; + for (const [name, screen] of allScreens()) { + const board = await tutorialBoard(engine, screen.position, screen.onRoll); + for (const side of ["player", "computer"] as const) { + const s = board.sides[side]; + assert.equal( + s.points.reduce((a, b) => a + b, 0) + s.bar + s.off, + 15, + `${name} : ${side}`, + ); + } + assert.equal(board.result, null, `${name} : partie finie`); + if (screen.pips !== null) { + counts++; + assert.deepEqual( + [board.sides.player.pipCount, board.sides.computer.pipCount], + screen.pips, + `${name} : comptes de points`, + ); + } + } + assert.ok(counts >= 4, "la leçon 4 cite les comptes de points"); +}); + +test("chaque coup attendu est le meilleur coup du moteur à 2 plis, avec plus de 0,04 d'avance", async () => { + const engine = await loadNodeEngine(); + const report: string[] = []; + for (const [name, screen] of allScreens()) { + const notation = screen.expected[0]; + if (notation === undefined || screen.roll === null) { + continue; + } + const ranked = await rankOf(engine, screen.position, screen.roll, notation); + assert.equal(ranked.rank, 1, `${name} : le moteur préfère ${ranked.best} à ${notation}`); + assert.ok( + ranked.gap > MIN_GAP, + `${name} : ${notation} devant ${ranked.second} de ${ranked.gap.toFixed(3)} seulement`, + ); + report.push(`${name} : ${notation} (+${ranked.gap.toFixed(3)} sur ${ranked.second})`); + } + assert.equal(report.length, 16); +}); + +test("chaque coup illustré est le meilleur coup du moteur à 2 plis", async () => { + const engine = await loadNodeEngine(); + let illustrated = 0; + for (const [name, screen] of allScreens()) { + if (screen.move === null || screen.roll === null) { + continue; + } + illustrated++; + const ranked = await rankOf(engine, screen.position, screen.roll, screen.move); + assert.equal(ranked.rank, 1, `${name} : le moteur préfère ${ranked.best} à ${screen.move}`); + assert.ok( + ranked.gap > MIN_GAP, + `${name} : ${screen.move} seulement +${ranked.gap.toFixed(3)}`, + ); + } + assert.ok(illustrated >= 5); +}); + +test("chaque exercice accepte le coup du moteur et refuse tous les autres", async () => { + const engine = await loadNodeEngine(); + for (const [name, screen] of allScreens()) { + if (screen.expected.length === 0 || screen.roll === null) { + continue; + } + const legal = await engine.legalMoves(screen.position, screen.roll); + assert.ok(legal.length > 1, `${name} : rien à refuser`); + for (const move of legal) { + const practice = await MovePractice.start( + engine, + screen.position, + screen.roll, + screen.expected, + ); + let verdict = "continue"; + for (const halfMove of move.halfMoves) { + verdict = await practice.play(halfMove); + if (verdict !== "continue") { + break; + } + } + const right = screen.expected.includes(move.notation); + assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`); + } + } +}); + +// ------------------------------------------------------- Progression + +const outlines: LessonOutline[] = [ + { number: 1, exercises: [1, 2, 3, 4] }, + { number: 2, exercises: [1, 2, 3] }, + { number: 3, exercises: [1, 2] }, +]; + +test("la progression enregistrée se relit, même absente, abîmée ou d'une autre version", () => { + assert.deepEqual(readLessonProgress(undefined), NO_PROGRESS); + assert.deepEqual(readLessonProgress("texte"), NO_PROGRESS); + assert.deepEqual(readLessonProgress({ version: 99, lessons: {} }), NO_PROGRESS); + assert.deepEqual(readLessonProgress({ version: 1, lessons: null }), NO_PROGRESS); + const saved = { + version: LESSON_PROGRESS_VERSION, + lessons: { + 1: { last: 3, reached: 3, solved: [2, 1, 2, -1, 1.5] }, + 2: { last: "x", reached: 1, solved: [] }, + x: { last: 1, reached: 1, solved: [] }, + 4: { last: LESSON_END, reached: LESSON_END, solved: [1] }, + }, + }; + assert.deepEqual(readLessonProgress(JSON.parse(JSON.stringify(saved))), { + version: 1, + lessons: { + 1: { last: 3, reached: 3, solved: [1, 2] }, + 4: { last: LESSON_END, reached: LESSON_END, solved: [1] }, + }, + }); +}); + +test("les états d'une leçon : à commencer, « 2 exercices sur 4 », terminée", () => { + let progress = NO_PROGRESS; + const first = outlines[0] as LessonOutline; + assert.deepEqual(lessonState(progress, first), { kind: "toStart", total: 4 }); + assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "À commencer · 4 exercices"); + progress = withVisit(progress, 1, 1); + assert.deepEqual(lessonState(progress, first), { kind: "inProgress", solved: 0, total: 4 }); + progress = withSolved(withSolved(withVisit(progress, 1, 2), 1, 1), 1, 2); + progress = withSolved(progress, 1, 2); + assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "2 exercices sur 4"); + assert.deepEqual(lessonRecord(progress, 1), { last: 2, reached: 2, solved: [1, 2] }); + // Revenir en arrière garde l'étape la plus loin vue. + progress = withVisit(progress, 1, 1); + assert.deepEqual(lessonRecord(progress, 1), { last: 1, reached: 2, solved: [1, 2] }); + progress = withSolved(withSolved(progress, 1, 3), 1, 4); + assert.deepEqual(lessonState(progress, first), { kind: "done", total: 4 }); + assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "Terminée"); + assert.equal( + LESSON_TEXTS.state({ kind: "inProgress", solved: 1, total: 3 }), + "1 exercice sur 3", + ); +}); + +test("la leçon proposée : la première en cours, sinon la première à commencer", () => { + assert.equal(nextLesson(NO_PROGRESS, outlines)?.number, 1); + let progress = withVisit(NO_PROGRESS, 2, 1); + assert.equal(nextLesson(progress, outlines)?.number, 2); + for (const step of [1, 2, 3]) { + progress = withSolved(progress, 2, step); + } + assert.equal(nextLesson(progress, outlines)?.number, 1, "leçon 2 terminée"); + assert.equal(finishedLessons(progress, outlines), 1); + for (const [number, steps] of [ + [1, [1, 2, 3, 4]], + [3, [1, 2]], + ] as const) { + for (const step of steps) { + progress = withSolved(progress, number, step); + } + } + assert.equal(nextLesson(progress, outlines), null, "toutes terminées"); + assert.equal(finishedLessons(progress, outlines), 3); +}); + +test("« Continuer » reprend là où l'on a quitté la leçon", () => { + const first = outlines[0] as LessonOutline; + assert.equal(resumeStep(NO_PROGRESS, first), 1); + let progress = withVisit(NO_PROGRESS, 1, 3); + assert.equal(resumeStep(progress, first), 3); + // Après la fin, le premier exercice pas encore réussi. + progress = withSolved(withVisit(progress, 1, LESSON_END), 1, 1); + assert.equal(resumeStep(progress, first), 2); +}); + +test("la progression est enregistrée dans le stockage des parties et relue", async () => { + const storage = memoryStorage(); + const store = createLessonStore(createKeyValueBackend(storage)); + assert.deepEqual(await store.load(), NO_PROGRESS); + const progress = withSolved(withVisit(NO_PROGRESS, 3, 2), 3, 2); + void store.save(progress); + // Les enregistrements passent avant la lecture qui suit. + assert.deepEqual(await store.load(), progress); + assert.deepEqual(JSON.parse(storage.getItem("ustagammon.lessons") ?? "null"), progress); + const again = createLessonStore(createKeyValueBackend(storage)); + assert.deepEqual(await again.load(), progress); + // Une valeur illisible n'empêche rien. + storage.setItem("ustagammon.lessons", "{abîmé"); + assert.deepEqual(await again.load(), NO_PROGRESS); + // Un stockage qui refuse l'écriture non plus. + const refused = createLessonStore({ + lessonProgress: async () => { + throw new Error("refusé"); + }, + saveLessonProgress: async () => { + throw new Error("refusé"); + }, + }); + await refused.save(progress); + assert.deepEqual(await refused.load(), NO_PROGRESS); +}); + +test("la liste des leçons et la carte de l'accueil", () => { + const empty = lessonCards(lessons, NO_PROGRESS); + assert.deepEqual( + empty.map((c) => [c.number, c.state.kind, c.current, c.href]), + [ + [1, "toStart", true, "#/lecons/1/1"], + [2, "toStart", false, "#/lecons/2/1"], + [3, "toStart", false, "#/lecons/3/1"], + [4, "toStart", false, "#/lecons/4/1"], + [5, "toStart", false, "#/lecons/5/1"], + ], + ); + let progress = withSolved(withVisit(NO_PROGRESS, 2, 2), 2, 1); + const card = nextLessonCard(lessons, progress); + assert.equal(card?.number, 2); + assert.equal(card?.href, "#/lecons/2/2"); + assert.deepEqual(card?.exercises, [true, false, false]); + assert.equal( + LESSON_TEXTS.state(card?.state ?? { kind: "toStart", total: 0 }), + "1 exercice sur 3", + ); + for (const lesson of lessons) { + for (const step of outlineOf(lesson).exercises) { + progress = withSolved(progress, lesson.number, step); + } + } + assert.equal(nextLessonCard(lessons, progress), null); +}); + +test("les écrans d'une leçon ont leur adresse, ramenée à un écran qui existe", () => { + const lesson = lessons[0] as Lesson; + assert.deepEqual( + lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: 2, practice: true }), + { + step: 2, + practice: true, + }, + ); + assert.deepEqual( + lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: null, practice: false }), + { + step: 1, + practice: false, + }, + ); + assert.deepEqual( + lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: 9, practice: false }), + { + step: 1, + practice: false, + }, + ); + assert.equal(lessonPlaceOf(lesson, { screen: "lessonEnd", lesson: 1 }), "end"); + assert.deepEqual(lessonPlaceRoute(3, { step: 2, practice: true }), { + screen: "lesson", + lesson: 3, + step: 2, + practice: true, + }); + assert.deepEqual(lessonPlaceRoute(3, "end"), { screen: "lessonEnd", lesson: 3 }); +}); diff --git a/app/tests/router.test.ts b/app/tests/router.test.ts index 814acb3..d1f22ee 100644 --- a/app/tests/router.test.ts +++ b/app/tests/router.test.ts @@ -34,6 +34,12 @@ const TABLE: [string, Route][] = [ ["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }], ["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }], ["#/tutoriel/fin", { screen: "tutorialEnd" }], + // Les leçons (amendement du 2026-11-12, 1g). + ["#/lecons", { screen: "lessons" }], + ["#/lecons/2", { screen: "lesson", lesson: 2, step: null, practice: false }], + ["#/lecons/2/3", { screen: "lesson", lesson: 2, step: 3, practice: false }], + ["#/lecons/2/3/a-vous", { screen: "lesson", lesson: 2, step: 3, practice: true }], + ["#/lecons/2/fin", { screen: "lessonEnd", lesson: 2 }], ["#/installer", { screen: "install" }], ["#/parties", { screen: "games", game: null }], ["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }], @@ -99,6 +105,14 @@ test("une adresse inconnue est reconnue comme telle", () => { "#/tutoriel/3/b", "#/tutoriel/3/a-vous/x", "#/tutoriel/fin/a-vous", + "#/lecons/0", + "#/lecons/12", + "#/lecons/x", + "#/lecons/2/0", + "#/lecons/2/x", + "#/lecons/2/3/b", + "#/lecons/2/3/a-vous/x", + "#/lecons/2/fin/a-vous", "#/installer/x", "#/a-propos/x", "#/position", diff --git a/app/tests/steps/suivre-une-lecon.steps.ts b/app/tests/steps/suivre-une-lecon.steps.ts new file mode 100644 index 0000000..d7dd3d7 --- /dev/null +++ b/app/tests/steps/suivre-une-lecon.steps.ts @@ -0,0 +1,205 @@ +// Étapes du scénario docs/scenarios/suivre-une-lecon.feature (ADR 0008) : +// le texte des leçons est lu dans sa source (docs/lecons/), chaque geste est +// jugé par le domaine branché sur le vrai moteur, et la progression passe +// par le stockage des parties, comme dans l'application. +// +// 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 { BeforeAll, Given, Then, When } from "@cucumber/cucumber"; +import type { Engine } from "../../src/domain/game/engine.js"; +import { + type LessonProgress, + lessonState, + NO_PROGRESS, + withSolved, + withVisit, +} from "../../src/domain/learning/lessons.js"; +import { + MovePractice, + type PracticeVerdict, + tutorialBoard, +} from "../../src/domain/learning/tutorial.js"; +import { + createKeyValueBackend, + type KeyValueStorage, +} from "../../src/storage/key-value-backend.js"; +import { createLessonStore, type LessonStore } from "../../src/storage/lesson-store.js"; +import { memoryStorage } from "../../src/storage/open-library.js"; +import { LESSON_COUNT, type Lesson, outlineOf, parseLesson } from "../../src/ui/lesson-text.js"; +import { LESSON_TEXTS } from "../../src/ui/lesson-texts.js"; +import { lessonCards, nextLessonCard } from "../../src/ui/lessons-model.js"; +import type { TutorialScreen } from "../../src/ui/tutorial-text.js"; +import { loadNodeEngine } from "../support/node-engine.js"; + +const root = new URL("../../../../", import.meta.url); + +let engine: Engine; +let lessons: Lesson[]; +let storage: KeyValueStorage; +let store: LessonStore; +let progress: LessonProgress = NO_PROGRESS; +let screen: TutorialScreen; +let practice: MovePractice | null = null; +let verdict: PracticeVerdict = "continue"; + +BeforeAll(async () => { + engine = await loadNodeEngine(); + lessons = Array.from({ length: LESSON_COUNT }, (_, i) => + parseLesson(i + 1, readFileSync(new URL(`docs/lecons/lecon-${i + 1}.md`, root), "utf8")), + ); +}); + +function lesson(n: number): Lesson { + const found = lessons[n - 1]; + assert.ok(found !== undefined, `leçon ${n} absente`); + return found; +} + +/** Enregistre la progression, comme l'application à chaque écran. */ +async function save(next: LessonProgress): Promise { + progress = next; + await store.save(progress); +} + +Given("vous ouvrez les leçons pour la première fois", async () => { + storage = memoryStorage(); + store = createLessonStore(createKeyValueBackend(storage)); + progress = await store.load(); + assert.deepEqual(progress, NO_PROGRESS); +}); + +Then("il y en a cinq, chacune avec deux à quatre exercices", () => { + assert.equal(lessons.length, 5); + for (const card of lessonCards(lessons, progress)) { + assert.equal(card.state.kind, "toStart"); + assert.ok(card.state.total >= 2 && card.state.total <= 4); + } +}); + +Then("chaque position des leçons se lit sur le plateau", async () => { + for (const current of lessons.flatMap((l) => l.steps.flatMap((s) => [s.explain, s.practice]))) { + if (current !== null) { + const board = await tutorialBoard(engine, current.position, current.onRoll); + assert.equal(board.sides.player.points.length, 24); + } + } +}); + +Then("l'accueil vous propose la leçon {int}", (n: number) => { + assert.equal(nextLessonCard(lessons, progress)?.number, n); +}); + +When("vous réussissez le premier exercice de la leçon {int}", async (n: number) => { + const first = outlineOf(lesson(n)).exercises[0]; + assert.ok(first !== undefined); + await save(withSolved(withVisit(progress, n, first), n, first)); +}); + +Then( + "la leçon {int} en est à « {int} exercice sur {int} »", + (n: number, solved: number, total: number) => { + const state = lessonState(progress, outlineOf(lesson(n))); + assert.deepEqual(state, { kind: "inProgress", solved, total }); + assert.equal(LESSON_TEXTS.state(state), `${solved} exercice sur ${total}`); + }, +); + +Then("l'accueil vous propose de continuer la leçon {int}", (n: number) => { + const card = nextLessonCard(lessons, progress); + assert.equal(card?.number, n); + assert.equal(card?.state.kind, "inProgress"); +}); + +Given("vous êtes au premier exercice de la leçon {int}", async (n: number) => { + const step = lesson(n).steps.find((s) => s.practice !== null); + assert.ok(step?.practice != null && step.practice.roll !== null); + screen = step.practice; + practice = await MovePractice.start( + engine, + screen.position, + screen.roll as [number, number], + screen.expected, + ); + verdict = "continue"; +}); + +When( + "vous avancez un pion de la flèche {int} à la flèche {int}", + async (from: number, to: number) => { + assert.ok(practice !== null); + verdict = await practice.play({ from, to }); + if (verdict === "wrong") { + practice.reset(); + } + }, +); + +Then("le pion revient, et le maître vous met sur la voie", () => { + assert.equal(verdict, "wrong"); + assert.deepEqual(practice?.pendingHalfMoves, []); + assert.match(screen.wrong.map((part) => part.text).join(""), /^Ce coup est permis, mais/); +}); + +When("vous faites le point {int}", async (point: number) => { + assert.ok(practice !== null && screen.roll !== null); + // Le coup du maître de cet exercice, demi-coup par demi-coup : il + // amène bien deux pions sur la flèche demandée. + const legal = await engine.legalMoves(screen.position, screen.roll); + const move = legal.find((m) => m.notation === screen.expected[0]); + assert.ok(move !== undefined); + assert.equal(move.halfMoves.filter((h) => h.to === point).length, 2); + for (const halfMove of move.halfMoves) { + verdict = await practice.play(halfMove); + } +}); + +Then("le maître salue votre coup", () => { + assert.equal(verdict, "right"); + assert.match(screen.right.map((part) => part.text).join(""), /^C'est (aussi )?mon coup/); +}); + +Then("c'est le meilleur coup du moteur", async () => { + assert.ok(screen.roll !== null); + const best = await engine.bestMove( + screen.position, + { value: 1, owner: "centered" }, + screen.roll, + 2, + ); + assert.equal(best?.notation, screen.expected[0]); +}); + +Given("vous avez quitté la leçon {int} à sa deuxième étape", async (n: number) => { + storage = memoryStorage(); + store = createLessonStore(createKeyValueBackend(storage)); + progress = NO_PROGRESS; + await save(withVisit(withVisit(progress, n, 1), n, 2)); +}); + +When("vous revenez plus tard aux leçons", async () => { + store = createLessonStore(createKeyValueBackend(storage)); + progress = await store.load(); +}); + +Then("« Continuer » vous ramène à la deuxième étape de la leçon {int}", (n: number) => { + const card = lessonCards(lessons, progress).find((c) => c.number === n); + assert.equal(card?.href, `#/lecons/${n}/2`); + assert.equal(nextLessonCard(lessons, progress)?.number, n); +}); + +When("vous réussissez tous les exercices de la leçon {int}", async (n: number) => { + let next = progress; + for (const step of outlineOf(lesson(n)).exercises) { + next = withSolved(withVisit(next, n, step), n, step); + } + await save(next); +}); + +Then("la leçon {int} est terminée", (n: number) => { + const state = lessonState(progress, outlineOf(lesson(n))); + assert.equal(state.kind, "done"); + assert.equal(LESSON_TEXTS.state(state), "Terminée"); +}); diff --git a/docs/adr/0013-position-dans-l-adresse.md b/docs/adr/0013-position-dans-l-adresse.md index 693abce..1d03239 100644 --- a/docs/adr/0013-position-dans-l-adresse.md +++ b/docs/adr/0013-position-dans-l-adresse.md @@ -192,3 +192,29 @@ demande suivie de `/ia` : - Le prompt contient, lui, le lien public de la position (`https://ustagammon.sans.pub/#/position//`), qui se partage. + +### 2026-11-12 — Adresses des leçons + +Ajoutées avec les cinq premières leçons (étape 1g), dans le même schéma, +sur le modèle du tutoriel : + +| Écran | Adresse | +| --- | --- | +| Liste des leçons | `#/lecons` | +| Une étape d'une leçon (son écran d'explication) | `#/lecons//<étape>` | +| L'exercice « à vous » d'une étape | `#/lecons//<étape>/a-vous` | +| Fin d'une leçon | `#/lecons//fin` | + +- `` est le numéro de la leçon (1 à 5 pour l'instant), `<étape>` celui + d'une étape de la leçon, à partir de 1 ; `#/lecons/` mène à sa + première étape. Le `<écran>` du plan de l'étape 1g se décline ainsi en + `<étape>`, `<étape>/a-vous` ou `fin`, comme pour le tutoriel. +- Une leçon qui n'existe pas mène à la liste (`#/lecons`), une étape qui + n'existe pas à la première étape de la leçon, un exercice absent à + l'explication de son étape : jamais une page cassée. +- « Continuer » (la liste, la carte de l'accueil) mène à l'étape où l'on a + quitté la leçon ; « Quitter » et « Retour » sur la première étape + ramènent à la liste ; l'action de la fin mène à la leçon suivante, ou à la + liste après la dernière. Le menu a une ligne « Les leçons ». +- Ces adresses se partagent : le contenu des leçons est le même pour tous ; + seule la progression (barre, état) dépend de l'appareil. diff --git a/docs/etape-1.md b/docs/etape-1.md index 34e3972..211f7fd 100644 --- a/docs/etape-1.md +++ b/docs/etape-1.md @@ -23,7 +23,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001 | 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a | | 1e. Fin de partie et analyse (fait) | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d | | 1f. Prompts à copier (fait) | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e | -| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a | +| 1g. Accueil, tutoriel et leçons (fait) | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 livrés sans relecture préalable, sous les garde-fous du moteur (décision du 2026-11-09) | 0e.5, 1a | | 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e | | 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 | | 1j. Plan de test | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes | diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 7eac25d..adad922 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -65,4 +65,5 @@ Règles d'écriture : | [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e | | [decouvrir-l-application.feature](decouvrir-l-application.feature) | Premier lancement et accueil | Étape 0e.5 | | [apprendre-les-regles.feature](apprendre-les-regles.feature) | Apprendre les règles avec le tutoriel | Étape 0e.5 | +| [suivre-une-lecon.feature](suivre-une-lecon.feature) | Progresser avec les leçons, garder sa progression | Étape 1g | | [installer-l-application.feature](installer-l-application.feature) | Installer l'application | Étape 0e.5 | diff --git a/docs/scenarios/suivre-une-lecon.feature b/docs/scenarios/suivre-une-lecon.feature new file mode 100644 index 0000000..0c7e9f0 --- /dev/null +++ b/docs/scenarios/suivre-une-lecon.feature @@ -0,0 +1,46 @@ +# language: fr +Fonctionnalité: Progresser avec les leçons + + Cinq leçons courtes, sur le vrai plateau : les ouvertures, construire ses + cases, frapper ou non, la course et le compte de points, sortir sans se + faire frapper. Chacune explique une idée en quelques écrans, puis vous + fait jouer le coup du maître. GNU Backgammon vérifie chaque coup. + + Scénario: Comment se passent les leçons ? + Les leçons sont dans le menu, et l'accueil propose la prochaine. Chaque + leçon a de deux à quatre exercices « à vous ». La liste montre où vous + en êtes : à commencer, « 1 exercice sur 3 », terminée. L'ordre est + conseillé, mais toutes les leçons restent ouvertes. + + Étant donné que vous ouvrez les leçons pour la première fois + Alors il y en a cinq, chacune avec deux à quatre exercices + Et chaque position des leçons se lit sur le plateau + Et l'accueil vous propose la leçon 1 + Quand vous réussissez le premier exercice de la leçon 2 + Alors la leçon 2 en est à « 1 exercice sur 3 » + Et l'accueil vous propose de continuer la leçon 2 + + @position:4HPwATDgc/ABMA + Scénario: Je joue un autre coup que celui du maître + Sur un exercice, vous jouez sur le vrai plateau. Si votre coup n'est pas + celui du maître, le pion revient et une phrase vous met sur la voie ; + réessayez autant que vous voulez. Le coup attendu est toujours celui + que GNU Backgammon préfère, nettement devant les autres. + + Étant donné que vous êtes au premier exercice de la leçon 1 + Quand vous avancez un pion de la flèche 13 à la flèche 11 + Alors le pion revient, et le maître vous met sur la voie + Quand vous faites le point 4 + Alors le maître salue votre coup + Et c'est le meilleur coup du moteur + + Scénario: Ma progression est-elle gardée ? + Oui : chaque exercice réussi est enregistré sur votre téléphone, avec + vos parties. Une leçon est terminée quand tous ses exercices sont + réussis ; « Continuer » vous ramène là où vous l'avez quittée. + + Étant donné que vous avez quitté la leçon 3 à sa deuxième étape + Quand vous revenez plus tard aux leçons + Alors « Continuer » vous ramène à la deuxième étape de la leçon 3 + Quand vous réussissez tous les exercices de la leçon 3 + Alors la leçon 3 est terminée diff --git a/tests/browser.sh b/tests/browser.sh index f232419..b1e7d51 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -18,10 +18,12 @@ # build/browser-shots/nouvelle-partie/. Les retours des premiers joueurs # (tests/browser/retours-joueurs.mjs : pions sur la barre, numéros sur le # cadre, flèches du tutoriel, en thème clair et sombre), captures dans -# build/browser-shots/retours-joueurs/. +# build/browser-shots/retours-joueurs/. Les leçons (tests/browser/lecons.mjs : +# carte de l'accueil, liste, explication, exercice refusé puis réussi, +# progression gardée), captures dans build/browser-shots/lecons/. # # BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan, -# prompts, nouvelle-partie, retours-joueurs) restreignent le test. +# prompts, nouvelle-partie, retours-joueurs, lecons) restreignent le test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, # installé une fois par npm dans build/browser-tools/ (réseau nécessaire la @@ -34,7 +36,7 @@ cd "$(dirname "$0")/.." image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}" browsers="${BROWSERS:-chromium firefox}" # Les scripts à lancer (tests/browser/.mjs), tous par défaut. -scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs}" +scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs lecons}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -73,7 +75,7 @@ rm -rf build/browser-shots mkdir -p build/browser-shots cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \ tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \ - tests/browser/retours-joueurs.mjs "$tools/" + tests/browser/retours-joueurs.mjs tests/browser/lecons.mjs "$tools/" status=0 for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker @@ -136,5 +138,12 @@ for browser in $browsers; do -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node retours-joueurs.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi + if [[ " $scripts " == *" lecons "* ]]; then + # Les leçons : carte de l'accueil, liste, exercices, progression ; + # captures dans build/browser-shots/lecons/. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node lecons.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/lecons.mjs b/tests/browser/lecons.mjs new file mode 100644 index 0000000..99e782b --- /dev/null +++ b/tests/browser/lecons.mjs @@ -0,0 +1,284 @@ +// 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 . Lancé par tests/browser.sh. Captures de la recette (Chromium) +// dans /lecons/, en 412×915 et 915×412. + +import { mkdirSync } from "node:fs"; +import { chromium, firefox } from "playwright"; +import { hiddenUnderActionBar, 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 "); + 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 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; + }); + 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("0 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"); + 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)}`); + 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(); diff --git a/tests/test_site.sh b/tests/test_site.sh index f610bc4..2a77bbc 100755 --- a/tests/test_site.sh +++ b/tests/test_site.sh @@ -87,6 +87,12 @@ cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \ check "docs/tutoriel/regles.md" "text/markdown" cmp -s <(curl -s "$base/docs/tutoriel/regles.md") docs/tutoriel/regles.md \ || report "/docs/tutoriel/regles.md diffère de la source" +# Leçons : copie de leur source unique (ADR 0008, étape 1g). +for lesson in 1 2 3 4 5; do + check "docs/lecons/lecon-$lesson.md" "text/markdown" + cmp -s <(curl -s "$base/docs/lecons/lecon-$lesson.md") "docs/lecons/lecon-$lesson.md" \ + || report "/docs/lecons/lecon-$lesson.md diffère de la source" +done # Prompts pour une IA : copie de leur source unique (ADR 0008, étape 1f). for template in coup videau bilan concept exercices; do check "docs/prompts/$template.md" "text/markdown" diff --git a/web/app.css b/web/app.css index 7ccfe51..3752687 100644 --- a/web/app.css +++ b/web/app.css @@ -1849,6 +1849,191 @@ html.bare .topbar { height: 28px; } +/* Leçons (1g) : la liste (#/lecons) et la carte « prochaine leçon » de + * l'accueil (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »). */ +.lessons { + max-width: var(--page-width); + margin: 0 auto; + padding-bottom: var(--space-6); +} + +.lessons-intro { + padding-top: var(--space-2); +} + +.lessons-intro h2 { + margin: 0 0 var(--space-2); + font-size: 32px; + line-height: 36px; + font-weight: 800; + letter-spacing: -0.01em; +} + +.lessons-intro p { + margin: 0; +} + +.lessons-rules { + display: flex; + align-items: center; + min-height: 44px; + margin-top: var(--space-2); +} + +.lessons-rules a { + display: inline-flex; + align-items: center; + min-height: 44px; + margin-left: var(--space-1); + font-weight: 700; + color: var(--accent); +} + +#lessons-status:empty { + display: none; +} + +.lesson-list { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin: var(--space-2) 0 0; + padding: 0; + list-style: none; +} + +.lesson-card { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.lesson-card.current { + border: 2px solid var(--accent); +} + +.lesson-card-top { + display: flex; + gap: var(--space-3); + align-items: center; +} + +.lesson-card-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.lesson-card-title { + font-size: 17px; + line-height: 25px; + font-weight: 700; +} + +.lesson-card-state { + font-size: 15px; + line-height: 20px; + color: var(--muted); +} + +.lesson-card-state.done { + color: var(--verdict-good); + font-weight: 700; +} + +/* La pastille ronde au numéro : contour pour une leçon à commencer, brique + * pour la leçon proposée, verte une fois terminée. */ +.lesson-badge { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + font-size: 17px; + font-weight: 800; + color: var(--text); + border: 2px solid var(--border-strong); + border-radius: var(--radius-pill); +} + +.lesson-badge.current { + color: var(--on-accent); + background: var(--accent); + border-color: var(--accent); +} + +.lesson-badge.done { + color: var(--on-accent); + background: var(--verdict-good); + border-color: var(--verdict-good); +} + +/* La barre de progression : un segment par exercice. */ +.lesson-progress { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + gap: var(--space-1); + margin-top: var(--space-1); +} + +.lesson-progress .segment { + display: block; + height: 6px; + background: var(--surface-sunken); + border-radius: var(--radius-pill); +} + +.lesson-progress .segment.done { + background: var(--accent); +} + +.lesson-card-action { + align-self: stretch; +} + +.home-lesson { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.home-lesson-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.home-lesson-info .muted { + font-size: 15px; + line-height: 20px; +} + +.home-lesson-actions { + display: flex; + gap: var(--space-3); + align-items: center; +} + +.home-lesson-actions .button { + flex: 1 1 0; + min-width: 0; + padding: 0 12px; +} + +.home-lessons-link { + display: inline-flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + min-height: 44px; + font-weight: 700; + color: var(--accent); +} + /* Feuille d'installation. */ .sheet-head .install-symbol { width: 40px; @@ -1909,7 +2094,8 @@ html.bare .topbar { .tutorial-progress { display: grid; flex: 1; - grid-template-columns: repeat(8, minmax(0, 1fr)); + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; gap: var(--space-1); } @@ -1966,7 +2152,7 @@ html.bare .topbar { } } -#tutorial-replay { +.tutorial-board-box .tutorial-replay { position: absolute; right: var(--space-1); bottom: calc(-44px - var(--space-1)); @@ -2119,7 +2305,7 @@ html.landscape .tutorial-board { background: var(--frame); } -html.landscape #tutorial-replay { +html.landscape .tutorial-board-box .tutorial-replay { position: static; grid-row: 2; grid-column: 2; diff --git a/web/index.html b/web/index.html index 48bc2ad..e61d8cb 100644 --- a/web/index.html +++ b/web/index.html @@ -96,6 +96,11 @@ est construit par app/src/ui/home-view.ts et tutorial-view.ts. --> + + + @@ -146,6 +151,7 @@ +