Isolate RNG drafts and fix image editor lifecycle
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-11 20:28:07 +02:00
parent 7c0b72f914
commit b470a34b2a
7 changed files with 297 additions and 50 deletions

View file

@ -0,0 +1,166 @@
// Rôle : vérifie les brouillons RNG et les changements d'image sans état périmé sous StrictMode.
/* global window, document */
import { test, expect } from "@playwright/test";
import { fileURLToPath, URL } from "node:url";
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/editor-lifecycle-probe.js", import.meta.url))}`;
async function setup(page, kind, props = {}) {
await page.goto("/");
await page.evaluate(async ({ url, kind, props }) => {
const { mountEditorProbe, controlImageDecoding } = await import(url);
document.getElementById("app").style.display = "none";
const element = document.createElement("div");
element.id = "editor-probe";
document.body.prepend(element);
if (kind === "cover" || kind === "viewer") {
const canvas = document.createElement("canvas");
canvas.width = 800;
canvas.height = 400;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#22d3ee";
ctx.fillRect(0, 0, 800, 400);
window.probeImage = canvas.toDataURL();
if (kind === "cover") {
window.probeFile = await new Promise((resolve) => canvas.toBlob(resolve));
window.decoding = controlImageDecoding();
if (props.existingCover) props.existingCover = window.probeImage;
else props.file = window.probeFile;
} else {
window.viewerImage = {
dataUrl: window.probeImage, canAnnotate: true,
markers: [{ id: "a", label: "First", x: 30, y: 30 }],
onChangeMarkers: () => {}, onChangeDrawings: () => {}
};
props.image = window.viewerImage;
}
}
window.editorProbe = mountEditorProbe(element, kind, props);
}, { url: probeUrl, kind, props });
return page.locator("#editor-probe");
}
test("RNG drafts follow rate and module identity without losing unrelated edits", async ({ page }) => {
const rates = [
{ id: "a", label: "First", rate: "10", costPerDraw: "12" },
{ id: "b", label: "Second", rate: "20", costPerDraw: "34", occurrenceValue: 2, occurrenceUnit: "perDay" }
];
const editor = await setup(page, "rng", { data: { rates, activeRateId: "a" } });
const cost = editor.getByLabel("Coût d'un tirage");
await cost.fill("999");
await page.evaluate(() => window.editorProbe.update({}));
await expect(cost).toHaveValue("999");
const snapshots = await page.evaluate((rates) => {
window.editorProbe.update({ data: { rates, activeRateId: "b" } });
return window.editorProbe.snapshots;
}, rates);
expect(snapshots.length).toBeGreaterThan(0);
for (const values of snapshots) {
expect(values).toContain("34");
expect(values).not.toContain("999");
}
await editor.getByRole("tab", { name: "Prévisions", exact: true }).click();
await expect(editor.getByRole("combobox", { name: "Période", exact: true })).toHaveValue("perDay");
await cost.fill("777");
await page.evaluate(() => window.editorProbe.update({ moduleId: "other" }));
await expect(cost).toHaveValue("34");
await cost.fill("56");
await cost.press("Tab");
expect(await page.evaluate(() => window.editorProbe.saves.at(-1).rates[1].costPerDraw)).toBe("56");
await page.evaluate(() => window.editorProbe.update({ editing: true }));
const label = editor.locator('.rng-simulator-rate-form input').first();
await label.fill("Unsubmitted");
await page.evaluate(() => window.editorProbe.update({ editing: false }));
await expect(label).toHaveCount(0);
await page.evaluate(() => window.editorProbe.update({ editing: true }));
await expect(label).toHaveValue("");
});
test("viewer resets markers and drawing tools before committing a replacement image", async ({ page }) => {
const editor = await setup(page, "viewer");
const marker = editor.getByRole("textbox", { name: "Libellé du marqueur 1" });
await marker.fill("Edited");
await editor.getByTitle("Dessiner", { exact: true }).click();
await editor.getByTitle("Gommer", { exact: true }).click();
await page.evaluate(() => window.editorProbe.update({}));
await expect(marker).toHaveValue("Edited");
await expect(editor.getByTitle("Gommer", { exact: true })).toHaveAttribute("aria-pressed", "true");
const snapshots = await page.evaluate(() => {
window.editorProbe.update({ image: {
...window.viewerImage, drawingMode: "temporary",
markers: [{ id: "b", label: "Second", x: 60, y: 60 }]
} });
return window.editorProbe.snapshots;
});
expect(snapshots.length).toBeGreaterThan(0);
for (const values of snapshots) expect(values).toEqual(["Second"]);
await expect(editor.getByTitle("Dessiner", { exact: true })).toHaveAttribute("aria-pressed", "false");
await editor.getByTitle("Dessiner", { exact: true }).click();
await expect(editor.getByTitle("Gommer", { exact: true })).toHaveAttribute("aria-pressed", "false");
await expect(editor.getByRole("button", { name: "Annotations temporaires" })).toBeVisible();
await page.evaluate(() => window.editorProbe.unmount());
});
test("cover ignores obsolete decodes and releases every bitmap and object URL", async ({ page }) => {
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const editor = await setup(page, "cover");
const save = editor.getByRole("button", { name: "Valider", exact: true });
await expect(save).toBeDisabled();
expect(await page.evaluate(() => window.decoding.pending.length)).toBe(2);
await page.evaluate(async () => {
await window.decoding.resolve(0);
await window.decoding.resolve(1);
});
await expect(save).toBeEnabled();
await expect(editor.locator("img")).toBeVisible();
await editor.getByLabel("Zoom", { exact: true }).press("End");
await save.click();
expect(await page.evaluate(() => window.editorProbe.saves[0])).toMatch(/^data:image\/(webp|jpeg);base64,/);
await page.evaluate(() => window.editorProbe.update({ file: window.probeFile.slice() }));
await expect(save).toBeDisabled();
await expect(editor.locator("img")).toHaveCount(0);
await page.evaluate(() => window.editorProbe.update({ file: window.probeFile.slice() }));
await page.evaluate(async () => {
await window.decoding.resolve(3);
window.decoding.reject(2);
});
await expect(save).toBeEnabled();
await expect(editor.locator(".toolbox-cover-editor-error")).toHaveCount(0);
await page.evaluate(() => window.editorProbe.update({ file: window.probeFile.slice() }));
await page.evaluate(() => window.decoding.reject(4));
await expect(save).toBeDisabled();
await expect(editor.locator(".toolbox-cover-editor-error")).toBeVisible();
await page.evaluate(() => {
window.editorProbe.update({ file: window.probeFile.slice() });
window.editorProbe.unmount();
});
await page.evaluate(() => window.decoding.resolve(5));
const resources = await page.evaluate(() => {
window.decoding.restore();
return { created: window.decoding.created, revoked: window.decoding.revoked, closed: window.decoding.closed };
});
expect(resources.revoked.sort()).toEqual(resources.created.sort());
expect(resources.closed.sort()).toEqual([0, 1, 3, 5]);
expect(errors).toEqual([]);
});
test("cover loads an existing image and clears it when the source is removed", async ({ page }) => {
const editor = await setup(page, "cover", { existingCover: true });
await expect.poll(() => page.evaluate(() => window.decoding.pending.length)).toBe(2);
await page.evaluate(async () => {
await window.decoding.resolve(0);
await window.decoding.resolve(1);
});
await expect(editor.getByRole("button", { name: "Valider", exact: true })).toBeEnabled();
await page.evaluate(() => window.editorProbe.update({ existingCover: "" }));
await expect(editor.locator("img")).toHaveCount(0);
await expect(editor.getByRole("button", { name: "Valider", exact: true })).toBeDisabled();
const resources = await page.evaluate(() => {
window.editorProbe.unmount();
window.decoding.restore();
return { created: window.decoding.created, closed: window.decoding.closed };
});
expect(resources.created).toEqual([]);
expect(resources.closed.sort()).toEqual([0, 1]);
});

View file

@ -0,0 +1,85 @@
// Rôle : monte les éditeurs réels sous StrictMode et contrôle les décodages concurrents.
/* global URL */
import { createElement, StrictMode, useLayoutEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { ImageViewer } from "../../website/src/components/ImageViewer.jsx";
import { ToolboxCoverEditor } from "../../website/src/features/toolboxes/ToolboxCoverEditor.jsx";
import { RngSimulatorModule } from "../../website/src/features/toolboxes/modules/RngSimulatorModule.jsx";
import { normalizeRngSimulatorData } from "../../website/src/features/toolboxes/storage/toolboxStorage.js";
export function mountEditorProbe(element, kind, initialProps) {
const root = createRoot(element);
const snapshots = [];
const saves = [];
let updateProps;
const components = { viewer: ImageViewer, cover: ToolboxCoverEditor, rng: RngSimulatorModule };
function Probe() {
const [props, setProps] = useState(initialProps);
updateProps = setProps;
useLayoutEffect(() => {
snapshots.push(Array.from(element.querySelectorAll("input"), (input) => input.value));
});
const context = {
getModuleData: () => props.data,
normalizeRngSimulatorData,
setModuleData: (_toolbox, _module, data) => {
saves.push(data);
setProps((current) => ({ ...current, data }));
},
uid: () => "new-rate"
};
return createElement(components[kind], {
toolboxId: "probe", moduleId: "rng", context,
onClose: () => {}, onSave: (value) => saves.push(value),
setEditing: (editing) => setProps((current) => ({ ...current, editing })),
...props
});
}
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe))));
return {
snapshots, saves,
update(patch) {
snapshots.length = 0;
flushSync(() => updateProps((props) => ({ ...props, ...patch })));
},
unmount: () => flushSync(() => root.unmount())
};
}
export function controlImageDecoding() {
const originalDecode = globalThis.createImageBitmap;
const originalCreate = URL.createObjectURL;
const originalRevoke = URL.revokeObjectURL;
const pending = [];
const created = [];
const revoked = [];
const closed = [];
globalThis.createImageBitmap = (file) => new Promise((resolve, reject) => {
pending.push({ file, resolve, reject });
});
URL.createObjectURL = (file) => {
const url = originalCreate(file);
created.push(url);
return url;
};
URL.revokeObjectURL = (url) => {
revoked.push(url);
originalRevoke(url);
};
return {
pending, created, revoked, closed,
async resolve(index) {
const bitmap = await originalDecode(pending[index].file);
const close = bitmap.close.bind(bitmap);
bitmap.close = () => { closed.push(index); close(); };
pending[index].resolve(bitmap);
},
reject: (index) => pending[index].reject(new Error("decode failed")),
restore() {
globalThis.createImageBitmap = originalDecode;
URL.createObjectURL = originalCreate;
URL.revokeObjectURL = originalRevoke;
}
};
}