From 898fd5286f14d7e7516e0a11d7942596ea41804d Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Sat, 12 Sep 2026 09:08:10 +0200 Subject: [PATCH] Fix toolbox tutorial state across navigation --- .beads/interactions.jsonl | 1 + docs/FEATURE_CHECKLIST.md | 1 + e2e/fixtures/toolbox-tutorial-probe.js | 39 ++++++++++++ e2e/toolbox-tutorial.spec.js | 62 +++++++++++++++++++ .../src/features/toolboxes/ToolboxPages.jsx | 19 +++--- 5 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 e2e/fixtures/toolbox-tutorial-probe.js create mode 100644 e2e/toolbox-tutorial.spec.js diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index d9e5b16..154efeb 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -34,3 +34,4 @@ {"id":"int-80659dd55d71f2a33d1067b008bf2abd","kind":"field_change","created_at":"2026-09-11T17:30:09.794064421Z","actor":"Shinuwa","issue_id":"sokko-g-275","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed RNG draft/edit transitions and ImageViewer pre-commit resets; cover decoding now guards stale completion/errors and releases resources. Four new StrictMode E2E cases pass; npm run check:full passes with 27 E2E. No storage or style changes."}} {"id":"int-6ad8b329d6c9580520514a4234fc5230","kind":"field_change","created_at":"2026-09-11T18:38:38.171744457Z","actor":"Shinuwa","issue_id":"sokko-g-rcs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed lazy game loading without synchronous effect state updates; AbortController reaches editorial, game and nested list requests. Stale outcomes ignored, navigation cache preserved. Three new StrictMode behavior tests; npm run check:full passes with 30 E2E; targeted set-state-in-effect lint passes."}} {"id":"int-9cb5a663c6e65593625066b25976439c","kind":"field_change","created_at":"2026-09-11T19:15:51.529868089Z","actor":"Shinuwa","issue_id":"sokko-g-5i3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Library category now derives from anchor navigation; same-page hash/history navigation works; malformed anchors handled and obsolete RAF/scroll timers cleaned up. Existing library coverage and two new behavioral E2E pass. npm run check:full passes with 32 E2E; targeted set-state-in-effect lint passes."}} +{"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index cfdfb23..481b0c9 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -18,6 +18,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu. - Vérifier l'affichage dans la page toolbox complète. - Vérifier l'affichage dans le panneau latéral. +- Pour le tutoriel toolbox, vérifier le démarrage unique, la fermeture et le changement de toolbox en cours de visite ; une autre toolbox doit démarrer à la première étape et ne pas consommer les indicateurs de la précédente. - Pour le Bloc Note, vérifier la sélection, l'ajout, le renommage et la suppression de pages en page complète, panneau latéral et mobile. - Pour les dessins temporaires et l'historique du simulateur de chance, vérifier l'isolation par clé de session lors des changements de page/outil et après rechargement ; une navigation seule ne doit pas écraser les données de l'autre clé. - Pour le simulateur de chance, vérifier que les brouillons suivent le taux et l'outil actifs, restent intacts lors d'un rendu sans rapport et se valident au blur selon le champ. diff --git a/e2e/fixtures/toolbox-tutorial-probe.js b/e2e/fixtures/toolbox-tutorial-probe.js new file mode 100644 index 0000000..a43c3fe --- /dev/null +++ b/e2e/fixtures/toolbox-tutorial-probe.js @@ -0,0 +1,39 @@ +// Rôle : monte la page toolbox sous StrictMode pour contrôler les demandes de tutoriel et les changements d'identité. +import { createElement, StrictMode, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { ToolboxPage } from "../../website/src/features/toolboxes/ToolboxPages.jsx"; + +export function mountToolboxTutorialProbe(element, siteContent) { + const root = createRoot(element); + const writes = []; + const pendingChanges = []; + let setInputs; + function Probe() { + const [inputs, updateInputs] = useState({ + toolboxId: "a", pendingTutorialToolboxId: "", + toolboxes: ["a", "b", "c"].map((id) => ({ id, name: id, modules: [], tutorial: id !== "c" })) + }); + setInputs = updateInputs; + return createElement(ToolboxPage, { + ...inputs, siteContent, actions: {}, + getToolboxGame: () => null, + getModuleData: (_toolbox, _module, fallback) => fallback, + updateModuleData: () => {}, + updateToolbox: (toolbox) => { + writes.push(toolbox); + updateInputs((current) => ({ ...current, toolboxes: current.toolboxes.map((item) => item.id === toolbox.id ? toolbox : item) })); + }, + setPendingTutorialToolboxId: (id) => { + pendingChanges.push(id); + updateInputs((current) => ({ ...current, pendingTutorialToolboxId: id })); + } + }); + } + flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe)))); + return { + writes, pendingChanges, + update: (patch) => flushSync(() => setInputs((current) => ({ ...current, ...patch }))), + unmount: () => flushSync(() => root.unmount()) + }; +} diff --git a/e2e/toolbox-tutorial.spec.js b/e2e/toolbox-tutorial.spec.js new file mode 100644 index 0000000..7d5bc85 --- /dev/null +++ b/e2e/toolbox-tutorial.spec.js @@ -0,0 +1,62 @@ +// Rôle : vérifie les étapes, la fermeture et l'isolation des tutoriels toolbox sous StrictMode. +/* global window, document, fetch */ +import { test, expect } from "@playwright/test"; +import { fileURLToPath, URL } from "node:url"; + +const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/toolbox-tutorial-probe.js", import.meta.url))}`; + +async function setup(page) { + await page.goto("/"); + await page.evaluate(async (url) => { + const { mountToolboxTutorialProbe } = await import(url); + const siteContent = await fetch("/data/site.json").then((response) => response.json()); + document.getElementById("app").style.display = "none"; + const element = document.createElement("div"); + document.body.prepend(element); + window.tutorialProbe = mountToolboxTutorialProbe(element, siteContent); + }, probeUrl); +} + +test("tutorial restarts on another toolbox and consumes each start flag once", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await setup(page); + const tour = page.locator(".toolbox-tutorial-tooltip"); + const count = tour.locator(".toolbox-tutorial-count"); + await expect(count).toHaveText(/^1\//); + await tour.locator("footer button").click(); + await expect(count).toHaveText(/^2\//); + await page.evaluate(() => window.tutorialProbe.update({})); + await expect(count).toHaveText(/^2\//); + await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "b" })); + await expect(count).toHaveText(/^1\//); + expect(await page.evaluate(() => window.tutorialProbe.writes.map(({ id, tutorial }) => ({ id, tutorial })))).toEqual([ + { id: "a", tutorial: false }, { id: "b", tutorial: false } + ]); + await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "c" })); + await expect(tour).toHaveCount(0); + await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "a" })); + await expect(tour).toHaveCount(0); + await page.evaluate(() => window.tutorialProbe.unmount()); + expect(errors).toEqual([]); +}); + +test("pending tutorial survives unrelated toolboxes and stays dismissed after rerenders", async ({ page }) => { + await setup(page); + const tour = page.locator(".toolbox-tutorial-tooltip"); + await expect(tour).toBeVisible(); + await tour.locator(".toolbox-tutorial-close").click(); + await page.evaluate(() => window.tutorialProbe.update({ pendingTutorialToolboxId: "c" })); + await expect(tour).toHaveCount(0); + expect(await page.evaluate(() => window.tutorialProbe.pendingChanges)).toEqual([]); + await page.evaluate(() => window.tutorialProbe.update({ toolboxId: "c" })); + await expect(tour.locator(".toolbox-tutorial-count")).toHaveText(/^1\//); + expect(await page.evaluate(() => window.tutorialProbe.pendingChanges)).toEqual([""]); + await tour.locator(".toolbox-tutorial-close").click(); + await page.evaluate(() => window.tutorialProbe.update({})); + await expect(tour).toHaveCount(0); + await page.evaluate(() => window.tutorialProbe.update({ pendingTutorialToolboxId: "c" })); + await expect(tour.locator(".toolbox-tutorial-count")).toHaveText(/^1\//); + await page.evaluate(() => window.tutorialProbe.unmount()); + await expect(tour).toHaveCount(0); +}); diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index a981810..a2eda72 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -181,12 +181,17 @@ function shouldShowTutorial(toolbox, pendingTutorialToolboxId) { function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) { const toolboxId = toolbox.id; - const toolboxTutorial = toolbox.tutorial; const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2; const toolboxGameCover = getGameCardCover(toolboxGame); const moduleText = siteContent.toolboxes.modules; const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId)); - const currentToolboxIdRef = useRef(toolboxId); + const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId); + const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested }); + // Consommer la demande de lancement ne doit pas interrompre la visite en cours. + if (previousTutorial.toolboxId !== toolboxId || previousTutorial.requested !== tutorialRequested) { + setPreviousTutorial({ toolboxId, requested: tutorialRequested }); + if (previousTutorial.toolboxId !== toolboxId || tutorialRequested) setShowTutorial(tutorialRequested); + } const moduleContext = { getModuleData, storageReady, @@ -222,15 +227,6 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto refresh: () => {} }; - useEffect(() => { - if (currentToolboxIdRef.current === toolboxId) { - if (shouldShowTutorial(toolbox, pendingTutorialToolboxId)) setShowTutorial(true); - return; - } - currentToolboxIdRef.current = toolboxId; - setShowTutorial(shouldShowTutorial(toolbox, pendingTutorialToolboxId)); - }, [pendingTutorialToolboxId, toolbox, toolboxId, toolboxTutorial]); - function addModule(type, initialData = null) { if (!type) return; const module = { id: uid("mod"), type }; @@ -285,6 +281,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto {!embedded && showTutorial && (