// Rôle : vérifie les lectures à la demande, les exports complets et la conservation des médias non chargés. /* global window, document, IDBObjectStore, DOMException */ import { test, expect } from "@playwright/test"; import { Buffer } from "node:buffer"; async function setup(page) { await page.route("**/__media-seed", (route) => route.fulfill({ contentType: "text/html", body: "Seed" })); await page.goto("/__media-seed"); const seeded = await page.evaluate(async () => { const db = await import("/src/utils/indexedDbStorage.js"); const canvas = document.createElement("canvas"); canvas.width = canvas.height = 32; const context = canvas.getContext("2d"); context.fillStyle = "red"; context.fillRect(0, 0, 32, 32); const red = canvas.toDataURL(); context.fillStyle = "green"; context.fillRect(0, 0, 32, 32); const green = canvas.toDataURL(); const targetAt = Date.now() + 60000; const toolboxes = [ { id: "a", name: "Alpha", updatedAt: "2026-09-12T12:00:00Z", modules: [ { id: "images", type: "images", title: "Images" }, { id: "map", type: "imageAnnotation", title: "Carte" }, { id: "timer", type: "timer", title: "Timer" } ] }, { id: "b", name: "Beta", updatedAt: "2026-09-12T12:00:00Z", modules: [{ id: "images", type: "images", title: "Images" }] } ]; const modules = { "a:images": { images: [{ id: "red", label: "Rouge", dataUrl: red }] }, "a:map": { image: green, markers: [{ id: "marker", x: 25, y: 50, label: "Entrée" }], drawings: { strokes: [{ id: "stroke", color: "#f6c453", width: 4, points: [{ x: 10, y: 10 }, { x: 80, y: 80 }] }] } }, "a:timer": { countdowns: [{ id: "alert", type: "duration", label: "Alerte fermée", targetAt, durationMs: 60000, alertMode: "site" }] }, "b:images": { images: [{ id: "green", label: "Vert", dataUrl: green }] } }; await db.setToolboxSnapshot(toolboxes, { mhwilds: "a" }, modules); return { toolboxes, modules, targetAt }; }); await page.addInitScript(() => { window.moduleReads = []; window.failedMediaKey = ""; const get = IDBObjectStore.prototype.get; IDBObjectStore.prototype.get = function (key) { if (this.name === "modules") { window.moduleReads.push(key); if (key === window.failedMediaKey) throw new DOMException("Média indisponible", "UnknownError"); } return get.call(this, key); }; }); await page.goto("/#/toolboxes"); await expect(page.locator(".toolbox-card")).toHaveCount(2); return seeded; } async function disk(page) { return page.evaluate(async () => { const db = await import("/src/utils/indexedDbStorage.js"); return { toolboxes: await db.getToolboxes(), modules: Object.fromEntries((await db.getAllModuleData()).map((entry) => [entry.key, entry.data])) }; }); } async function exportJson(page, buttonName = "Exporter tout") { const download = page.waitForEvent("download"); await page.getByRole("button", { name: buttonName, exact: true }).click(); const stream = await (await download).createReadStream(); const chunks = []; for await (const chunk of stream) chunks.push(chunk); return JSON.parse(Buffer.concat(chunks).toString()); } test("closed media stays unread while site-wide timer alerts still fire", async ({ page }) => { const seeded = await setup(page); expect(await page.evaluate(() => window.moduleReads)).not.toEqual([]); expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true); await page.clock.install({ time: new Date(seeded.targetAt - 3000) }); await page.clock.pauseAt(new Date(seeded.targetAt - 2000)); await page.clock.runFor(3000); await expect(page.getByRole("status").filter({ hasText: "Alerte fermée" })).toBeVisible(); expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true); }); test("creation, import and deletion preserve unread media and global exports include it", async ({ page }) => { const seeded = await setup(page); await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click(); await page.getByRole("textbox", { name: "Nom", exact: true }).fill("New"); await page.getByRole("button", { name: "Créer", exact: true }).click(); await expect.poll(async () => (await disk(page)).toolboxes.length).toBe(3); expect((await disk(page)).modules).toMatchObject(seeded.modules); expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true); await page.goto("/#/toolboxes"); await page.getByText("Importer une toolbox", { exact: true }).locator("input").setInputFiles({ name: "import.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify({ toolbox: { id: "source", name: "Imported", modules: [] }, modules: {} })) }); await expect(page.locator(".toolbox-card")).toHaveCount(4); expect((await disk(page)).modules).toMatchObject(seeded.modules); expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true); const exported = await exportJson(page); for (const key of ["a:images", "a:map", "b:images"]) expect(exported.modules[key]).toEqual(seeded.modules[key]); await page.getByRole("button", { name: "Supprimer Beta", exact: true }).click(); await page.getByRole("dialog").getByRole("button", { name: "Supprimer", exact: true }).click(); await expect.poll(async () => (await disk(page)).modules["b:images"]).toBeUndefined(); expect((await disk(page)).modules["a:images"]).toEqual(seeded.modules["a:images"]); expect((await disk(page)).modules["a:map"]).toEqual(seeded.modules["a:map"]); }); test("single exports include deferred media without reading other toolboxes or retaining it", async ({ page }) => { const seeded = await setup(page); const exported = await exportJson(page, "Exporter Alpha"); const imageModule = exported.toolbox.modules.find((module) => module.type === "images"); const mapModule = exported.toolbox.modules.find((module) => module.type === "imageAnnotation"); expect(exported.modules[imageModule.id].images[0].dataUrl).toBe(seeded.modules["a:images"].images[0].dataUrl); expect(exported.modules[mapModule.id].image).toBe(seeded.modules["a:map"].image); expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false); const reads = await page.evaluate(() => window.moduleReads.filter((key) => key === "a:images").length); await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click(); await expect(page.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl); expect(await page.evaluate(() => window.moduleReads.filter((key) => key === "a:images").length)).toBe(reads + 1); }); test("opening and editing one toolbox only reads its media and reuses it on return", async ({ page }) => { const seeded = await setup(page); await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click(); const image = page.locator('[data-module-type="images"] .image-preview img'); await expect(image).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl); await expect.poll(() => image.evaluate((element) => element.naturalWidth)).toBe(32); await expect(page.locator(".annotation-marker")).toHaveCount(1); expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false); const initialReads = await page.evaluate(() => window.moduleReads.length); await page.getByRole("button", { name: "Afficher l'ajout de Images", exact: true }).click(); await page.getByRole("textbox", { name: "Libellé de l'image", exact: true }).fill("Edited"); await expect.poll(async () => (await disk(page)).modules["a:images"].images[0].label).toBe("Edited"); await page.goto("/#/toolboxes"); await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click(); await expect(page.getByText("Edited", { exact: true })).toBeVisible(); expect(await page.evaluate(() => window.moduleReads.length)).toBe(initialReads); }); test("the game drawer loads only the linked toolbox media", async ({ page }) => { const seeded = await setup(page); await page.goto("/#/games/mhwilds"); await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click(); const drawer = page.locator("#toolbox-drawer .drawer-panel"); await expect(drawer.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl); await expect(drawer.locator(".annotation-marker")).toHaveCount(1); expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false); }); test("media read errors block only the affected tool and retry without overwriting it", async ({ page }) => { const seeded = await setup(page); await page.evaluate(() => { window.failedMediaKey = "a:images"; }); await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click(); const module = page.locator('[data-module-type="images"]'); await expect(module.getByRole("alert")).toHaveText("Média indisponible"); await page.getByRole("button", { name: "Afficher l'ajout de Images", exact: true }).click(); await expect(module.locator('input[type="file"], .image-preview')).toHaveCount(0); await expect(page.locator(".annotation-marker")).toHaveCount(1); expect((await disk(page)).modules["a:images"]).toEqual(seeded.modules["a:images"]); await module.getByRole("button", { name: "Réessayer" }).click(); await expect(module.getByRole("alert")).toHaveText("Média indisponible"); await page.evaluate(() => { window.failedMediaKey = ""; }); await module.getByRole("button", { name: "Réessayer" }).click(); await expect(module.locator(".image-preview img")).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl); }); test("failed media exports produce no partial download and can be retried", async ({ page }) => { const seeded = await setup(page); const downloads = []; page.on("download", (download) => downloads.push(download)); await page.evaluate(() => { window.failedMediaKey = "b:images"; }); await page.getByRole("button", { name: "Exporter tout", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Erreur de stockage local" }); await expect(dialog).toBeVisible(); expect(downloads).toHaveLength(0); await dialog.getByRole("button", { name: "Compris" }).click(); await page.evaluate(() => { window.failedMediaKey = ""; }); const exported = await exportJson(page); expect(exported.modules["b:images"]).toEqual(seeded.modules["b:images"]); expect(downloads).toHaveLength(1); });