// Rôle : vérifie copie, liaison persistante et progression des checklists dans les guides. import { test, expect } from "@playwright/test"; import { readFileSync } from "node:fs"; import { resetSokkoStorage } from "./helpers/storage.js"; import { importChecklistText } from "../website/src/features/toolboxes/modules/textImport/basic.js"; import { normalizeChecklistData } from "../website/src/features/toolboxes/storage/modules/checklist.js"; const route = "/#/games/aniimo/aniimos"; const storageKey = "sokkog:guide-checklists"; const controls = (page, id) => page.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: `Retirer ${id}`, exact: true }) }).locator(".guide-checklist-controls"); const controlsToggle = (page, id) => page.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: `Retirer ${id}`, exact: true }) }).getByRole("button", { name: "Configuration", exact: true }); const linkButton = (page, id) => controls(page, id).locator(".guide-checklist-link"); const activeButtons = (page) => page.locator('.guide-checklist-link[aria-pressed="true"]'); const hiddenSwitch = (page) => page.locator('.guide-checklist-controls[data-linked="true"]').getByRole("button", { name: "Masquer les cochés", exact: true }); const json = (path) => JSON.parse(readFileSync(`website/public/data/${path}`, "utf8")); const row = (label, qtyCurrent = 0) => ({ id: label, label, qtyCurrent }); async function seed(page, gameId = "aniimo", lists = { capture: [row("Loufeuteau")], perfect: [row("Loufeuteau", 1)], forms: [row("Loufeuteau — Forme de base", 1), row("Emberpup — Mountain Woods Form")], unrelated: [row("Unrelated")] }) { await resetSokkoStorage(page); await page.evaluate(async ({ gameId, lists }) => { await new Promise((resolve, reject) => { const request = globalThis.indexedDB.open("sokkog", 1); request.onsuccess = () => { const db = request.result; const tx = db.transaction(["kv", "modules"], "readwrite"); tx.objectStore("kv").put([ { id: "guide", name: "Guide toolbox", modules: Object.keys(lists).map((id) => ({ id, type: "checklist", title: id })), moduleColumns: 1 }, { id: "other", name: "Other toolbox", modules: [{ id: "outside", type: "checklist", title: "Outside" }] } ], "toolboxes"); tx.objectStore("kv").put({ [gameId]: "guide" }, "links"); for (const [id, items] of Object.entries(lists)) tx.objectStore("modules").put({ key: `guide:${id}`, data: { items } }); tx.objectStore("modules").put({ key: "other:outside", data: { items: [{ id: "outside", label: "Loufeuteau", qtyCurrent: 1 }] } }); tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = () => reject(tx.error); }; request.onerror = () => reject(request.error); }); }, { gameId, lists }); await page.reload(); } async function readModule(page, moduleId) { return page.evaluate(async (moduleId) => new Promise((resolve, reject) => { const request = globalThis.indexedDB.open("sokkog", 1); request.onsuccess = () => { const db = request.result; const tx = db.transaction("modules", "readonly"); const get = tx.objectStore("modules").get(`guide:${moduleId}`); get.onsuccess = () => resolve(get.result.data); tx.oncomplete = () => db.close(); tx.onerror = () => reject(tx.error); }; }), moduleId); } async function readCreatedChecklist(page, title) { return page.evaluate(async (title) => new Promise((resolve, reject) => { const request = globalThis.indexedDB.open("sokkog", 1); request.onsuccess = () => { const db = request.result; const tx = db.transaction(["kv", "modules"], "readonly"); const toolboxesRequest = tx.objectStore("kv").get("toolboxes"); const modulesRequest = tx.objectStore("modules").getAll(); tx.oncomplete = () => { const toolbox = toolboxesRequest.result.find((item) => item.id === "guide"); const module = toolbox.modules.find((item) => item.type === "checklist" && item.title === title); const data = modulesRequest.result.find((entry) => entry.key === `guide:${module?.id}`)?.data; const activeSpace = toolbox.spaces.find((space) => space.id === toolbox.activeSpaceId); db.close(); resolve({ module, data, activeModuleIds: activeSpace?.moduleOrder.one || [] }); }; tx.onerror = () => reject(tx.error); }; request.onerror = () => reject(request.error); }), title); } async function copyVisibleResults(page) { await page.locator(".results-copy-button").click(); const dialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" }); await dialog.getByRole("button", { name: "Copier le format texte", exact: true }).click(); await expect(dialog).toHaveCount(0); } async function openDrawer(page) { const drawer = page.locator("#toolbox-drawer"); if (!await drawer.locator(".drawer-panel").isVisible()) { const compactTrigger = page.getByRole("tab", { name: "Toolbox", exact: true }); if (await compactTrigger.isVisible()) await compactTrigger.click(); else await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click(); } await expect(drawer.locator(".drawer-panel")).toBeVisible(); return drawer; } async function closeDrawer(page) { await page.locator("#toolbox-drawer").getByRole("button", { name: "Fermer", exact: true }).click(); } async function openControls(page, id) { const toggle = id ? controlsToggle(page, id) : page.locator('.module-configuration-button[data-guide-linked="true"]'); await expect(toggle).toBeVisible(); if (await toggle.getAttribute("aria-expanded") === "false") await toggle.click(); } async function choose(page, id) { await openDrawer(page); await openControls(page, id); await linkButton(page, id).click(); await expect(linkButton(page, id)).toHaveAttribute("aria-pressed", "true"); await expect(activeButtons(page)).toHaveCount(1); await closeDrawer(page); } async function toggleHidden(page) { await openDrawer(page); await openControls(page); await hiddenSwitch(page).click(); await closeDrawer(page); } async function assertLink(page, id, hidden) { await openDrawer(page); if (id) await openControls(page, id); await expect(activeButtons(page)).toHaveCount(id ? 1 : 0); if (id) await expect(linkButton(page, id)).toHaveAttribute("aria-pressed", "true"); if (id) await expect(hiddenSwitch(page)).toHaveAttribute("aria-pressed", String(hidden)); else await expect(hiddenSwitch(page)).toHaveCount(0); await closeDrawer(page); } test("Aniimo copies visible species or forms and round-trips through checklist import", async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); await seed(page); await page.goto(route); await page.getByRole("searchbox").fill("emberpup"); await expect(page.locator(".guide-checklist-controls")).toHaveCount(0); const copy = page.locator(".results-copy-button"); await expect(copy).toHaveAccessibleName(/sans les variantes/); await expect(copy).toContainText("Utiliser les résultats"); await expect(page.locator('[data-copy-target="true"]')).toHaveText("1 / 88 Aniimos"); await copyVisibleResults(page); await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("Loufeuteau:1"); await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click(); await expect(copy).toHaveAccessibleName(/toutes les variantes/); await expect(page.locator('[data-copy-target="true"]')).toHaveText("3 / 208 variantes"); await copyVisibleResults(page); const text = await page.evaluate(() => globalThis.navigator.clipboard.readText()); const imported = importChecklistText(text, { uid: (prefix) => `${prefix}-${Math.random()}`, normalizeChecklistData }); expect(imported.items.map((item) => item.label)).toEqual(["Loufeuteau — Forme de base", "Loufeuteau — Forme des montagnes boisées", "Loufeuteau — Hautes-plaines"]); expect(imported.items.every((item) => item.qtyTarget === 1 && item.qtyCurrent === 0)).toBe(true); await choose(page, "forms"); await toggleHidden(page); await copyVisibleResults(page); expect(await page.evaluate(() => globalThis.navigator.clipboard.readText())).not.toContain("Forme de base"); await choose(page, "perfect"); await expect(copy).toBeDisabled(); await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible(); }); test("filtered results explain unavailable creation when no toolbox is linked", async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); await page.setViewportSize({ width: 390, height: 844 }); await resetSokkoStorage(page); await page.goto(route); await page.getByRole("searchbox").fill("emberpup"); await page.locator(".results-copy-button").click(); const dialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" }); await expect(dialog.getByText("Liez une toolbox à cette page pour créer directement la checklist.")).toBeVisible(); await expect(dialog.getByRole("button", { name: "Créer dans la toolbox", exact: true })).toBeDisabled(); expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true); await dialog.getByRole("button", { name: "Copier le format texte", exact: true }).click(); await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("Loufeuteau:1"); }); test("filtered results create a checklist in the linked active space without replacing the guide association", async ({ page }) => { await seed(page); await page.goto(route); await choose(page, "forms"); await page.getByRole("searchbox").fill("emberpup"); const associationBefore = await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey); await page.locator(".results-copy-button").click(); const dialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" }); await expect(dialog.getByText("Guide toolbox", { exact: true })).toBeVisible(); await expect(dialog.getByText("Aniimos", { exact: true })).toBeVisible(); await dialog.getByRole("button", { name: "Créer dans la toolbox", exact: true }).click(); await expect(dialog).toHaveCount(0); await expect(page.getByText("Checklist créée dans la toolbox associée.")).toBeVisible(); await expect.poll(async () => (await readCreatedChecklist(page, "Aniimos")).module?.id).toBeTruthy(); const created = await readCreatedChecklist(page, "Aniimos"); expect(created.activeModuleIds).toContain(created.module.id); expect(normalizeChecklistData(created.data).items.map((item) => item.label)).toEqual(["Loufeuteau"]); expect(normalizeChecklistData(created.data).items.every((item) => item.qtyTarget === 1 && item.qtyCurrent === 0)).toBe(true); expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBe(associationBefore); }); for (const width of [1280, 768]) { test(`drawer linking, page controls and persistent restoration at ${width}px`, async ({ page }) => { test.setTimeout(60000); await page.setViewportSize({ width, height: 900 }); await seed(page); await page.goto(route); await page.getByRole("searchbox").fill("emberpup"); const card = page.locator(".aniimo-result"); const pageControls = page.locator(".guide-checklist-page-controls"); const unlinkedSelect = pageControls.getByRole("combobox", { name: "Checklist associée", exact: true }); await expect(pageControls).toBeVisible(); await expect(unlinkedSelect).toHaveValue(""); await expect(unlinkedSelect).toContainText("Choisir une checklist"); await expect(pageControls.getByRole("button")).toHaveCount(0); await openDrawer(page); await expect(activeButtons(page)).toHaveCount(0); await expect(page.locator(".guide-checklist-controls")).toHaveCount(0); await expect(page.locator(".guide-checklist-controls select")).toHaveCount(0); await expect(controls(page, "unrelated")).toHaveCount(0); await expect(controlsToggle(page, "unrelated")).toHaveCount(1); await expect(controls(page, "capture")).toHaveCount(0); await openControls(page, "capture"); await expect(controls(page, "capture").getByRole("button", { name: "Masquer les cochés" })).toBeDisabled(); const before = await readModule(page, "forms"); const heading = controlsToggle(page, "forms"); await expect(heading).toHaveAttribute("aria-expanded", "false"); expect(await heading.evaluate((button) => button.previousElementSibling.classList.contains("module-edit-button"))).toBe(true); await heading.press("Enter"); await expect(heading).toHaveAttribute("aria-expanded", "true"); await expect(page.locator(`[id="${await heading.getAttribute("aria-controls")}"]`)).toHaveClass(/module-configuration-panel/); await linkButton(page, "forms").press("Enter"); await expect(linkButton(page, "forms")).toHaveAttribute("aria-pressed", "true"); const panel = controls(page, "forms"); await heading.press("Space"); await expect(heading).toHaveAttribute("aria-expanded", "false"); await expect(heading).toHaveAttribute("data-guide-linked", "true"); await expect(linkButton(page, "forms")).toBeHidden(); await expect(card).toHaveAttribute("data-checklist-complete", "true"); await heading.press("Enter"); await expect(linkButton(page, "forms")).toBeVisible(); const bounds = await panel.boundingBox(); const actionsBounds = await panel.locator(".guide-checklist-actions").boundingBox(); expect(actionsBounds.x + actionsBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0); await page.screenshot({ path: test.info().outputPath("linked-drawer.png") }); await closeDrawer(page); await expect(pageControls).toBeVisible(); const pageControlsBounds = await pageControls.boundingBox(); const pageHeadingBounds = await page.locator(".page-heading").boundingBox(); const headingActionsBounds = await page.locator(".game-heading-actions").boundingBox(); expect(pageControlsBounds.y).toBeGreaterThanOrEqual(pageHeadingBounds.y); expect(pageControlsBounds.y + pageControlsBounds.height).toBeLessThanOrEqual(pageHeadingBounds.y + pageHeadingBounds.height); if (width > 1024) { expect(headingActionsBounds.height).toBeCloseTo(pageHeadingBounds.height, 0); expect(pageControlsBounds.y).toBeCloseTo(pageHeadingBounds.y, 0); } const checklistSelect = pageControls.getByRole("combobox", { name: "Checklist associée", exact: true }); await expect(checklistSelect).toHaveValue("forms"); await expect(checklistSelect.locator("option")).toHaveCount(3); await expect(checklistSelect.locator('option[value="unrelated"]')).toHaveCount(0); await checklistSelect.selectOption("capture"); await expect(checklistSelect).toHaveValue("capture"); await checklistSelect.selectOption("forms"); await expect(checklistSelect).toHaveValue("forms"); await expect(pageControls.getByRole("button", { name: "Masquer les cochés", exact: true })).toHaveAttribute("aria-pressed", "false"); await expect(pageControls.getByRole("button", { name: "Dissocier la checklist", exact: true })).toBeVisible(); await expect(card).toHaveAttribute("data-checklist-complete", "true"); await expect(card.locator(".aniimo-front .guide-checklist-mark")).toHaveCount(0); await card.getByRole("button", { name: /Afficher les capacités/ }).click(); await expect(card.locator(".aniimo-back .guide-checklist-mark")).toHaveAccessibleName("Coché"); await card.getByRole("button", { name: /Afficher le portrait/ }).click(); const cardCheckbox = card.getByRole("checkbox", { name: "Décocher dans la checklist" }); await expect(cardCheckbox).toBeChecked(); await cardCheckbox.uncheck(); await expect(card).not.toHaveAttribute("data-checklist-complete", "true"); await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "true"); await expect.poll(async () => (await readModule(page, "forms")).items.find((item) => item.label.includes("Forme de base")).qtyCurrent || 0).toBe(0); await card.getByRole("checkbox", { name: "Cocher dans la checklist" }).check(); await expect(card).toHaveAttribute("data-checklist-complete", "true"); await pageControls.getByRole("button", { name: "Masquer les cochés", exact: true }).click(); await expect(card.getByRole("tab")).toHaveCount(2); await expect(card).toHaveAttribute("data-form", "mountain_woods_form"); expect(normalizeChecklistData(await readModule(page, "forms")).items).toEqual(normalizeChecklistData(before).items); await choose(page, "capture"); await assertLink(page, "capture", true); await expect(card.getByRole("tab")).toHaveCount(3); const drawer = await openDrawer(page); await drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) }).getByRole("checkbox", { name: "Terminer Loufeuteau", exact: true }).check(); await closeDrawer(page); await expect(card).toHaveCount(0); await expect.poll(() => page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).not.toBeNull(); await page.evaluate(() => globalThis.sessionStorage.clear()); await page.reload(); await assertLink(page, "capture", true); await page.getByRole("searchbox").fill("emberpup"); await expect(card).toHaveCount(0); await openDrawer(page); await openControls(page, "capture"); await hiddenSwitch(page).press("Space"); await closeDrawer(page); await expect(card).toHaveAttribute("data-checklist-complete", "true"); await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click(); await page.locator(".guide-checklist-page-controls").getByRole("button", { name: "Dissocier la checklist", exact: true }).click(); await openDrawer(page); await openControls(page, "capture"); await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "false"); await expect(activeButtons(page)).toHaveCount(0); await expect(hiddenSwitch(page)).toHaveCount(0); await closeDrawer(page); await expect(page.locator('[data-checklist-complete="true"]')).toHaveCount(0); expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull(); expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true); }); } for (const [gameId, category, cardClass, dataPath, dataKey, checklistTitle] of [ ["mhwilds", "monsters", ".monster-card", "mhwilds/monsters.json", "monsters", "Monstres"], ["mhwilds", "endemic", ".endemic-card", "mhwilds/endemic_life.json", "endemicLife", "Faune endémique"], ["diablo4", "affixes", ".diablo4-affix-card", "diablo4/affixes_types.json", "affixes", "Affixes"] ]) { test(`${gameId}/${category} links from its drawer and hides completed cards`, async ({ page }) => { const item = json(dataPath)[dataKey][0]; const label = gameId === "mhwilds" ? json("mhwilds/i18n/fr.json")[item.name] : item.label; await seed(page, gameId, { progress: [row(label, 1)] }); await page.goto(`/#/games/${gameId}/${category}`); await expect(page.locator(cardClass).first()).toBeVisible(); const resultsAction = page.locator(".results-copy-button"); await expect(resultsAction).toContainText("Utiliser les résultats"); await resultsAction.click(); const resultsDialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" }); await expect(resultsDialog.getByText(checklistTitle, { exact: true })).toBeVisible(); await resultsDialog.getByRole("button", { name: "Fermer", exact: true }).click(); const count = await page.locator(cardClass).count(); await choose(page, "progress"); await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1); const completedCard = page.locator(`${cardClass}[data-checklist-complete="true"]`); const cardCheckbox = completedCard.getByRole("checkbox", { name: "Décocher dans la checklist" }); await expect(cardCheckbox).toBeChecked(); await cardCheckbox.uncheck(); await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(0); await page.locator(cardClass).first().getByRole("checkbox", { name: "Cocher dans la checklist" }).check(); await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1); await toggleHidden(page); await expect(page.locator(cardClass)).toHaveCount(count - 1); await page.setViewportSize({ width: 390, height: 844 }); await page.reload(); await assertLink(page, "progress", true); await expect(page.locator(cardClass)).toHaveCount(count - 1); expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true); }); } test("renames retain a zero-match selection; changing toolbox clears the association", async ({ page }) => { await seed(page); await page.goto(route); await choose(page, "capture"); const drawer = await openDrawer(page); const checklist = drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) }); await openControls(page, "capture"); await checklist.getByRole("button", { name: "Modifier Loufeuteau", exact: true }).click(); await checklist.getByRole("textbox", { name: "Libellé", exact: true }).fill("Unrelated now"); await checklist.getByRole("textbox", { name: "Libellé", exact: true }).press("Enter"); await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "true"); await expect(page.getByText("Cette checklist ne contient plus de correspondance avec le guide.")).toBeVisible(); await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Lier une toolbox" }); await dialog.getByRole("combobox").selectOption("other"); await dialog.getByRole("button", { name: "Valider", exact: true }).click(); await expect(activeButtons(page)).toHaveCount(0); await expect(page.locator(".guide-checklist-controls")).toHaveCount(0); expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull(); }); test("deleting a selected checklist clears its link immediately", async ({ page }) => { await seed(page); await page.goto(route); await choose(page, "capture"); await toggleHidden(page); const drawer = await openDrawer(page); await drawer.getByRole("button", { name: "Retirer capture", exact: true }).click(); await page.getByRole("dialog").getByRole("button", { name: "Retirer", exact: true }).click(); await expect(activeButtons(page)).toHaveCount(0); await expect(hiddenSwitch(page)).toHaveCount(0); await page.reload(); await assertLink(page, "", false); expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull(); }); test("unavailable local storage retains links in memory across navigation; overview has no guide controls", async ({ page }) => { await seed(page); await page.addInitScript((key) => { for (const method of ["getItem", "setItem", "removeItem"]) { const original = globalThis.Storage.prototype[method]; globalThis.Storage.prototype[method] = function (name, ...args) { if (name === key) throw new Error("Local storage unavailable"); return original.call(this, name, ...args); }; } }, storageKey); await page.reload(); await page.goto(route); await choose(page, "perfect"); await toggleHidden(page); await page.goto("/#/games/aniimo"); await openDrawer(page); await expect(page.locator(".guide-checklist-controls")).toHaveCount(0); await closeDrawer(page); await page.goto("/#/toolbox/guide"); await expect(page.locator('[data-module-type="checklist"]')).toHaveCount(4); await expect(page.locator(".guide-checklist-controls")).toHaveCount(0); await expect(page.locator('.module-configuration-button[data-guide-linked="true"]')).toHaveCount(0); await page.goto(route); await assertLink(page, "perfect", true); await openDrawer(page); await page.goto("/#/toolboxes"); await expect(page.locator("#toolbox-drawer .drawer-panel")).toHaveCount(0); await expect(page.locator(".app-shell")).not.toHaveClass(/toolbox-drawer-open/); });