diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index a33c391..a277a17 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -49,3 +49,4 @@ {"id":"int-3823a391b02ae7a3d58956b24c10e998","kind":"field_change","created_at":"2026-09-13T18:43:29.457174095Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} {"id":"int-e3a2bea901894d43a31eb628b588648a","kind":"field_change","created_at":"2026-09-14T07:08:33.027716428Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} {"id":"int-20d1e0d862e6155eadf2c36eced314db","kind":"field_change","created_at":"2026-09-14T11:56:36.962825878Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-cc67f2645165e0eac9f57d71e8a34e57","kind":"field_change","created_at":"2026-09-14T12:11:10.207360007Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 191cd3b..cbfbd3d 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -28,6 +28,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - 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. - Pour le visualiseur d'images, vérifier la remise à zéro des marqueurs et des modes dessin/gomme au remplacement de l'image. - Pour l'éditeur de couverture, vérifier les décodages concurrents, les erreurs après fermeture et la libération des bitmaps/URLs ; une image en chargement ne doit pas permettre de valider la couverture précédente. +- Les parcours `e2e/toolbox-cover.spec.js` vérifient le choix d'icône, l'enregistrement d'une couverture 620 × 248, sa persistance après rechargement et import/export, l'annulation et les fichiers illisibles. L'association à un jeu doit afficher sa couverture sans perdre l'image personnalisée, restaurée après dissociation ; choisir une icône doit supprimer la couverture personnalisée. - Pour Mémos, vérifier l'édition courte, les couleurs, la réorganisation et la bande déroulante. - Vérifier qu'une notification ou une mise à jour sans rapport ne remplace pas un brouillon Mémos avant sa validation au blur. - Pour l'arbre d'objectifs, vérifier la sélection après suppression/import et le retour à Base quand le nœud sélectionné devient une étape sans onglet Contenu. diff --git a/e2e/toolbox-cover.spec.js b/e2e/toolbox-cover.spec.js new file mode 100644 index 0000000..4ecf2fe --- /dev/null +++ b/e2e/toolbox-cover.spec.js @@ -0,0 +1,124 @@ +// Rôle : vérifie la persistance et le remplacement des images de toolbox. +import { expect, test } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { Buffer } from "node:buffer"; +import { fileURLToPath, URL } from "node:url"; + +const imagePath = fileURLToPath(new URL("../website/public/static/img/toolbox-icons/sword.png", import.meta.url)); +const fixture = JSON.parse(await readFile(new URL("./fixtures/toolbox-export.json", import.meta.url), "utf8")); +const cover = `data:image/png;base64,${(await readFile(imagePath)).toString("base64")}`; +const { games } = JSON.parse(await readFile(new URL("../website/public/data/games.json", import.meta.url), "utf8")); +const gameCover = games.find((game) => game.id === "mhwilds").images.cardCover; + +function picker(page) { + return page.getByRole("button", { name: "Changer l'image de la toolbox" }); +} + +async function importToolbox(page, name, scope = page.getByText("Importer une toolbox", { exact: true })) { + await scope.locator('input[type="file"][accept="application/json"]').setInputFiles({ + name: "cover.json", mimeType: "application/json", + buffer: Buffer.from(JSON.stringify({ ...fixture, toolbox: { ...fixture.toolbox, name, customCover: cover } })), + }); +} + +async function uploadCover(page, file = imagePath) { + await picker(page).click(); + const action = page.getByRole("button", { name: /Ajouter une image de card|Remplacer l'image de card/ }); + await expect(action).toBeVisible(); + const chooser = page.waitForEvent("filechooser"); + await action.click(); + await (await chooser).setFiles(file); + return page.getByRole("dialog", { name: "Image de card", exact: true }); +} + +async function exportToolbox(page, name) { + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: `Exporter ${name}`, exact: true }).click(); + return JSON.parse(await readFile(await (await download).path(), "utf8")); +} + +test("saves a cropped cover through reload and export/import, then returns to an icon", async ({ page }) => { + await page.goto("/#/toolboxes"); + await page.getByRole("button", { name: "Nouvelle toolbox" }).click(); + const creation = page.getByRole("dialog", { name: "Nouvelle toolbox" }); + await creation.getByLabel("Nom").fill("Cover workflow"); + await creation.getByRole("button", { name: "Créer" }).click(); + await expect(picker(page).locator("img")).toHaveAttribute("src", /toolbox\.png$/); + await picker(page).click(); + await page.getByRole("button", { name: "Utiliser l'icône sword", exact: true }).click(); + const editor = await uploadCover(page); + await expect(editor.getByRole("button", { name: "Valider" })).toBeEnabled(); + await editor.getByRole("button", { name: "Valider" }).click(); + await expect(editor).toBeHidden(); + const savedCover = await picker(page).locator("img").getAttribute("src"); + expect(savedCover).toMatch(/^data:image\/(webp|jpeg);base64,/); + await expect.poll(() => picker(page).locator("img").evaluate((img) => [img.naturalWidth, img.naturalHeight])).toEqual([620, 248]); + await page.reload(); + await expect(picker(page).locator("img")).toHaveAttribute("src", savedCover); + await page.goto("/#/toolboxes"); + await expect(page.getByRole("link", { name: "Ouvrir Cover workflow", exact: true }).first().locator("img")).toHaveAttribute("src", savedCover); + const exported = await exportToolbox(page, "Cover workflow"); + expect(exported.toolbox.customCover).toBe(savedCover); + expect(exported.toolbox.icon).toMatch(/sword\.png$/); + exported.toolbox.name = "Imported cover"; + await page.getByText("Importer une toolbox", { exact: true }).locator('input[type="file"]').setInputFiles({ + name: "export.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(exported)), + }); + await page.getByRole("link", { name: "Ouvrir Imported cover", exact: true }).first().click(); + await expect(picker(page).locator("img")).toHaveAttribute("src", savedCover); + await picker(page).click(); + await expect(page.getByRole("button", { name: "Remplacer l'image de card" })).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("button", { name: "Utiliser l'icône sword", exact: true })).toHaveAttribute("aria-pressed", "false"); + await page.getByRole("button", { name: "Utiliser l'icône sword", exact: true }).click(); + await page.reload(); + await expect(picker(page).locator("img")).toHaveAttribute("src", /sword\.png$/); + await page.goto("/#/toolboxes"); + expect((await exportToolbox(page, "Imported cover")).toolbox.customCover || "").toBe(""); +}); + +test("cancelled and unreadable replacements preserve the cover and allow selecting the same file again", async ({ page }) => { + await page.goto("/#/toolboxes"); + await importToolbox(page, "Preserved cover"); + await page.getByRole("link", { name: "Ouvrir Preserved cover", exact: true }).first().click(); + let editor = await uploadCover(page); + await expect(editor.getByRole("button", { name: "Valider" })).toBeEnabled(); + await editor.getByRole("button", { name: "Annuler" }).click(); + await expect(picker(page).locator("img")).toHaveAttribute("src", cover); + editor = await uploadCover(page); + await expect(editor.getByRole("button", { name: "Valider" })).toBeEnabled(); + await editor.getByRole("button", { name: "Fermer", exact: true }).click(); + editor = await uploadCover(page, { name: "broken.png", mimeType: "image/png", buffer: Buffer.from("not an image") }); + await expect(editor.getByText("Image illisible. Choisis un autre fichier.")).toBeVisible(); + await expect(editor.getByRole("button", { name: "Valider" })).toBeDisabled(); + await editor.getByRole("button", { name: "Annuler" }).click(); + await page.reload(); + await expect(picker(page).locator("img")).toHaveAttribute("src", cover); +}); + +test("game association takes precedence without discarding the custom cover", async ({ page }) => { + await page.goto("/#/games/mhwilds"); + await page.getByRole("button", { name: "Associer une toolbox", exact: true }).click(); + const drawer = page.locator("#toolbox-drawer .drawer-panel"); + await importToolbox(page, "Linked cover", drawer); + await expect(drawer.getByRole("heading", { name: "Linked cover" })).toBeVisible(); + await drawer.getByRole("button", { name: "Fermer", exact: true }).click(); + await page.goto("/#/toolboxes"); + const link = page.getByRole("link", { name: "Ouvrir Linked cover", exact: true }).first(); + await expect(link.locator("img")).toHaveAttribute("src", gameCover); + await expect.poll(() => link.locator("img").evaluate((img) => img.naturalWidth)).toBeGreaterThan(0); + expect((await exportToolbox(page, "Linked cover")).toolbox.customCover).toBe(cover); + await link.click(); + await expect(page.locator(".toolbox-game-icon img")).toHaveAttribute("src", gameCover); + await expect(picker(page)).toHaveCount(0); + await page.goto("/#/games/mhwilds"); + await page.getByRole("button", { name: "Ouvrir la toolbox Linked cover", exact: true }).click(); + await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Lier une toolbox", exact: true }); + await dialog.getByLabel("Toolbox associée").selectOption(""); + await dialog.getByRole("button", { name: "Valider" }).click(); + await drawer.getByRole("button", { name: "Fermer", exact: true }).click(); + await page.goto("/#/toolboxes"); + await expect(link.locator("img")).toHaveAttribute("src", cover); + await link.click(); + await expect(picker(page).locator("img")).toHaveAttribute("src", cover); +}); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index af63ce5..7ab2c73 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -51,20 +51,6 @@ test("toolbox cards and pages retain their public UI conventions", async () => { assert.match(resetUtils, /export function getLatestDailyReset/); assert.match(resetUtils, /export function getLatestWeeklyReset/); assert.match(toolboxStorage, /nextId\("calendar"\)/); - assert.match(toolboxCard, /DEFAULT_TOOLBOX_ICON/); - assert.match(toolboxCard, /toolbox\.customCover/); - assert.match(toolboxCard, /customCoverImage = !gameCoverImage/); - assert.match(toolboxCard, /triggerImage = toolbox\.customCover \|\| icon/); - assert.match(toolboxCard, /has-custom-cover/); - assert.match(toolboxCard, /toolbox-cover-menu-action nds-button/); - assert.match(toolboxCard, /!toolbox\.customCover && path === icon/); - assert.match(toolboxCard, /aria-pressed=\{Boolean\(toolbox\.customCover\)\}/); - assert.match(toolboxCard, /Image personnalisée<\/span>/); - assert.match(toolboxCard, /onChange\(path, ""\)/); - assert.match(toolboxCard, /ToolboxCoverEditor/); - assert.match(toolboxCard, /accept="image\/\*"/); - assert.doesNotMatch(toolboxCard, /Modifier l'image de card/); - assert.doesNotMatch(toolboxCoverEditor, /Supprimer l'image/); assert.match(toolboxCard, /export function ToolboxCard/); assert.match(toolboxCard, /export function ToolboxIconPicker/); assert.match(toolboxCard, /export function ToolboxGameIcon/); @@ -73,7 +59,6 @@ test("toolbox cards and pages retain their public UI conventions", async () => { assert.match(toolboxCard, /toolbox-card-cover-link/); assert.match(toolboxCard, /toolbox-icon-cover/); assert.match(toolboxPages, /\.\/ToolboxCard\.jsx/); - assert.match(toolboxPages, /customCover = toolbox\.customCover/); assert.match(toolboxPages, /updateToolboxOrder/); assert.match(toolboxPages, /useGroupedReorder/); assert.match(toolboxPages, /useInlineEdit/); @@ -98,9 +83,6 @@ test("toolbox cards and pages retain their public UI conventions", async () => { assert.match(storageQuota, /role="progressbar"/); assert.match(importButton, /export function ImportButton/); assert.match(importButton, /accept="application\/json"/); - assert.match(toolboxCoverEditor, /image\/webp/); - assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/); - assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/); assert.doesNotMatch(toolboxCoverEditor, /react-easy-crop|react-image-crop/); });