// 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: "Ouvrir la toolbox Alpha", 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);
});