Strengthen shell behavior tests and profile toolbox loading
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-12 13:15:42 +02:00
parent a9b68a5712
commit b53c7c17c8
8 changed files with 323 additions and 61 deletions

View file

@ -37,3 +37,5 @@
{"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}} {"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}}
{"id":"int-c7fa4c94461f676392625658508ae231","kind":"field_change","created_at":"2026-09-12T10:15:48.480122568Z","actor":"Shinuwa","issue_id":"sokko-g-5rq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Notepad now separates page identity from focused editing, replaces the editor DOM on page/module changes, preserves selection during local echoes and applies external updates after blur without stealing focus. Removed obsolete selection helpers and timestamp-only echo detection. check:full passes with 36 E2E; global set-state-in-effect diagnostic passes."}} {"id":"int-c7fa4c94461f676392625658508ae231","kind":"field_change","created_at":"2026-09-12T10:15:48.480122568Z","actor":"Shinuwa","issue_id":"sokko-g-5rq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Notepad now separates page identity from focused editing, replaces the editor DOM on page/module changes, preserves selection during local echoes and applies external updates after blur without stealing focus. Removed obsolete selection helpers and timestamp-only echo detection. check:full passes with 36 E2E; global set-state-in-effect diagnostic passes."}}
{"id":"int-e79db6c1131fb3c5c57dde09917ef735","kind":"field_change","created_at":"2026-09-12T10:50:44.387707762Z","actor":"Shinuwa","issue_id":"sokko-g-8po","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Enabled app-wide StrictMode, restored recommended react-hooks/set-state-in-effect rule, documented development checks and added an effective ESLint configuration regression test. npm run check:full passed: lint, 101 Node tests, build and 36 E2E. All C10 subsets complete."}} {"id":"int-e79db6c1131fb3c5c57dde09917ef735","kind":"field_change","created_at":"2026-09-12T10:50:44.387707762Z","actor":"Shinuwa","issue_id":"sokko-g-8po","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Enabled app-wide StrictMode, restored recommended react-hooks/set-state-in-effect rule, documented development checks and added an effective ESLint configuration regression test. npm run check:full passed: lint, 101 Node tests, build and 36 E2E. All C10 subsets complete."}}
{"id":"int-4d9a5305507377a0f0d42d8c0e073c0e","kind":"field_change","created_at":"2026-09-12T11:13:23.757763936Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
{"id":"int-e86990d8d18afb8dec65036e0ab7712f","kind":"field_change","created_at":"2026-09-12T11:14:17.70653268Z","actor":"Shinuwa","issue_id":"sokko-g-usb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproducible production profiling completed and documented: npm run profile:toolboxes, four disk-backed scenarios and five genuine reloads each, 117.9 MiB identical media dataset. Raw and analysis reports in ignored .local/performance. Closed media retains about118MiB extra JS heap. check:full passes lint,101 Node tests,build,40 E2E. Initial read failure handling tracked separately as P1 sokko-g-d5u; no storage/schema changes."}}

View file

@ -74,6 +74,23 @@ effets de bord et les nettoyages manquants. Ces contrôles supplémentaires ne
s'exécutent pas en production. Les règles recommandées React Hooks, dont s'exécutent pas en production. Les règles recommandées React Hooks, dont
`react-hooks/set-state-in-effect`, sont actives dans ESLint. `react-hooks/set-state-in-effect`, sont actives dans ESLint.
Pour mesurer le chargement local des toolboxes, lancer `npm run profile:toolboxes`
avec Chromium Playwright installé (`npx playwright install chromium`). Le script
construit la production, utilise temporairement `127.0.0.1:4173` (le port doit être
libre) et crée ses données dans des profils Chromium temporaires sur disque,
sans toucher aux toolboxes personnelles. Il supprime uniquement ses profils
temporaires et ferme le navigateur et le serveur à la fin.
Les quatre scénarios comparent une base vide, 40 toolboxes légères, une toolbox
avec 160 images WebP synthétiques et les mêmes images réparties sur 40 toolboxes.
Cinq rechargements après une navigation de chauffe mesurent l'affichage de la
liste après lecture IndexedDB, la durée de lecture, les tâches longues et le tas
JavaScript après GC. Le rapport brut est écrit dans
`.local/performance/toolbox-loading.json`, hors Git. Exécuter ce profil seul, sans
build ou tests concurrents. Ce n'est ni un budget CI, ni une mesure du démarrage
à cache froid, ni de la mémoire totale du navigateur ; les résultats dépendent
de la machine et ne couvrent pas le décodage des images dans une toolbox ouverte.
## Contenu éditable ## Contenu éditable
Les contenus maintenus à la main sont regroupés dans `website/public/data`. Les contenus maintenus à la main sont regroupés dans `website/public/data`.

View file

@ -0,0 +1,21 @@
// Rôle : monte les vrais overlays et textes éditoriaux sous StrictMode pour tester leur cycle de vie.
import { createElement, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { ConfirmModal, CreateToolboxModal, NotificationToast } from "../../website/src/components/ToolboxModals.jsx";
import { RichText } from "../../website/src/components/RichText.jsx";
export function mountShellProbe(element) {
const root = createRoot(element);
const closed = [];
const onClose = (value) => closed.push(value);
function render({ confirm = false, create = false, toast = null, text = "" } = {}) {
flushSync(() => root.render(createElement(StrictMode, null,
confirm && createElement(ConfirmModal, { key: "confirm", title: "Confirmation test", message: "Continuer ?", onClose }),
create && createElement(CreateToolboxModal, { key: "create", onClose }),
toast && createElement(NotificationToast, { key: "toast", ...toast, onClose }),
createElement("div", { id: "editorial-text", key: "text" }, createElement(RichText, { text }))
)));
}
return { render, closed, unmount: () => root.unmount() };
}

View file

@ -0,0 +1,96 @@
// Rôle : couvre les durées des alertes, le nettoyage des overlays et le rendu éditorial sans inspecter leur source.
/* global window, document, CustomEvent */
import { test, expect } from "@playwright/test";
import { fileURLToPath, URL } from "node:url";
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/shell-probe.js", import.meta.url))}`;
async function setup(page, probe = false) {
await page.goto("/");
await expect(page.locator(".app-shell")).toBeVisible();
if (probe) {
await page.evaluate(async (url) => {
const { mountShellProbe } = await import(url);
const element = document.createElement("div");
element.id = "shell-probe";
document.body.append(element);
window.shellProbe = mountShellProbe(element);
}, probeUrl);
}
await page.clock.install({ time: new Date("2026-09-12T12:00:00Z") });
await page.clock.pauseAt(new Date("2026-09-12T12:00:01Z"));
}
async function notify(page, message, durationMs) {
await page.evaluate((detail) => window.dispatchEvent(new CustomEvent("sokkog:notify", { detail })), { message, durationMs });
await expect(page.getByRole("status").filter({ hasText: message })).toBeVisible();
}
test("application alerts use short clipboard and default durations and dismiss individually", async ({ page }) => {
await setup(page);
await notify(page, "Texte copié");
await notify(page, "Sauvegarde terminée");
await page.clock.runFor(4999);
await expect(page.getByRole("status")).toHaveCount(2);
await page.clock.runFor(1);
await expect(page.getByRole("status")).toHaveText("Sauvegarde terminée");
await page.clock.runFor(114999);
await expect(page.getByRole("status")).toHaveCount(1);
await page.clock.runFor(1);
await expect(page.getByRole("status")).toHaveCount(0);
await notify(page, "Minimum", 0);
await notify(page, "Personnalisée", 2000);
await page.clock.runFor(999);
await expect(page.getByRole("status")).toHaveCount(2);
await page.clock.runFor(1);
await expect(page.getByRole("status")).toHaveText("Personnalisée");
await page.getByRole("button", { name: "Fermer l'alerte" }).dispatchEvent("click");
await expect(page.getByRole("status")).toHaveCount(0);
await page.clock.runFor(2000);
await expect(page.getByRole("status")).toHaveCount(0);
});
test("toast replacement and unmount cancel obsolete expiration callbacks under StrictMode", async ({ page }) => {
await setup(page, true);
await page.evaluate(() => window.shellProbe.render({ toast: { id: "old", message: "Ancienne", durationMs: 1000 } }));
await page.clock.runFor(500);
await page.evaluate(() => window.shellProbe.render({ toast: { id: "new", message: "Nouvelle", durationMs: 2000 } }));
await page.clock.runFor(1999);
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual([]);
await page.clock.runFor(1);
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual(["new"]);
await page.evaluate(() => {
window.shellProbe.render({ toast: { id: "unmounted", message: "Fermée" } });
window.shellProbe.unmount();
});
await page.clock.runFor(120000);
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual(["new"]);
});
test("overlapping modals keep scroll locked until the last unmount under StrictMode", async ({ page }) => {
await setup(page, true);
await page.evaluate(() => window.shellProbe.render({ confirm: true, create: true }));
await expect(page.getByRole("dialog")).toHaveCount(2);
await expect(page.locator("body")).toHaveCSS("overflow-y", "hidden");
await page.evaluate(() => window.shellProbe.render({ create: true }));
await expect(page.getByRole("dialog")).toHaveCount(1);
await expect(page.locator("body")).toHaveCSS("overflow-y", "hidden");
await page.evaluate(() => window.shellProbe.unmount());
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.locator("body")).not.toHaveCSS("overflow-y", "hidden");
});
test("editorial text preserves line breaks and italics but never interprets arbitrary HTML", async ({ page }) => {
await setup(page, true);
await page.evaluate(() => window.shellProbe.render({ text: 'Première\n<i>Seconde</i>\n<img src=x onerror="alert(1)">' }));
const text = page.locator("#editorial-text");
await expect(text.locator("br")).toHaveCount(2);
await expect(text.locator("i")).toHaveText("Seconde");
await expect(text.locator("img, script")).toHaveCount(0);
await expect(text).toContainText('<img src=x onerror="alert(1)">');
await page.evaluate(() => window.shellProbe.render({ text: "Remplacé" }));
await expect(text).toHaveText("Remplacé");
await expect(text.locator("br, i")).toHaveCount(0);
await page.evaluate(() => window.shellProbe.unmount());
});

View file

@ -13,7 +13,8 @@
"check": "node --check server.mjs && node --check vite.config.js && npm run lint && npm run lists:index && npm test && npm run build", "check": "node --check server.mjs && node --check vite.config.js && npm run lint && npm run lists:index && npm test && npm run build",
"check:full": "npm run check && npm run test:e2e", "check:full": "npm run check && npm run test:e2e",
"test": "node --test", "test": "node --test",
"test:e2e": "playwright test" "test:e2e": "playwright test",
"profile:toolboxes": "npm run build && node scripts/profile-toolbox-loading.mjs"
}, },
"dependencies": { "dependencies": {
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",

View file

@ -0,0 +1,184 @@
// Rôle : mesure le démarrage du build de production avec des toolboxes synthétiques dans un navigateur isolé.
/* global window, document, indexedDB, IDBObjectStore, MutationObserver, requestAnimationFrame */
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { cpus, platform } from "node:os";
import { chromium } from "@playwright/test";
import { preview } from "vite";
const repetitions = 5;
const scenarios = [
{ name: "empty", toolboxes: 0, imagesPerToolbox: 0 },
{ name: "40-light", toolboxes: 40, imagesPerToolbox: 0 },
{ name: "1-heavy", toolboxes: 1, imagesPerToolbox: 160 },
{ name: "40-media", toolboxes: 40, imagesPerToolbox: 4 }
];
const requested = process.argv.slice(2);
for (const name of requested) assert.ok(scenarios.some((scenario) => scenario.name === name), `Unknown scenario: ${name}`);
async function seed(page, scenario) {
await page.route("**/__profile-seed", (route) => route.fulfill({ contentType: "text/html", body: "<!doctype html><title>Profile seed</title>" }));
await page.goto("/__profile-seed");
return page.evaluate(async ({ toolboxes: count, imagesPerToolbox }) => {
// Le PRNG fixe le contenu et la taille des images, indépendamment du regroupement en toolboxes.
const canvas = document.createElement("canvas");
canvas.width = 1024;
canvas.height = 768;
const context = canvas.getContext("2d");
const pixels = context.createImageData(canvas.width, canvas.height);
let random = 123456;
const boxes = [];
const modules = [];
let mediaBytes = 0;
for (let box = 0; box < count; box += 1) {
const id = `profile-${box}`;
const images = [];
for (let image = 0; image < imagesPerToolbox; image += 1) {
for (let pixel = 0; pixel < pixels.data.length; pixel += 4) {
random = (Math.imul(random, 1664525) + 1013904223) >>> 0;
pixels.data[pixel] = random & 255;
pixels.data[pixel + 1] = (random >>> 8) & 255;
pixels.data[pixel + 2] = (random >>> 16) & 255;
pixels.data[pixel + 3] = 255;
}
context.putImageData(pixels, 0, 0);
const dataUrl = canvas.toDataURL("image/webp", 0.85);
mediaBytes += dataUrl.length;
images.push({ id: `image-${image}`, label: `Image ${image}`, dataUrl });
}
boxes.push({
id, name: `Profile ${box}`, updatedAt: "2026-09-12T12:00:00.000Z",
modules: [{ id: "memos", type: "memos", title: "Mémos" }, { id: "images", type: "images", title: "Images" }]
});
modules.push({ key: `${id}:memos`, data: { items: [{ id: "memo", text: "Profil de chargement" }] } });
if (images.length) modules.push({ key: `${id}:images`, data: { images } });
}
await new Promise((resolve, reject) => {
const request = indexedDB.open("sokkog", 1);
request.onupgradeneeded = () => {
request.result.createObjectStore("kv");
request.result.createObjectStore("modules", { keyPath: "key" });
};
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(["kv", "modules"], "readwrite");
transaction.objectStore("kv").put(boxes, "toolboxes");
for (const module of modules) transaction.objectStore("modules").put(module);
transaction.oncomplete = () => { db.close(); resolve(); };
transaction.onabort = () => { db.close(); reject(transaction.error); };
transaction.onerror = () => reject(transaction.error);
};
});
return { mediaBytes, moduleCount: modules.length, imageCount: count * imagesPerToolbox };
}, scenario);
}
async function instrument(page, expectedCards) {
await page.addInitScript((count) => {
const metrics = { timeOrigin: performance.timeOrigin, reads: [], longTasks: [], readyMs: null };
window.toolboxProfile = metrics;
const observer = new MutationObserver(checkReady);
observer.observe(document, { childList: true, subtree: true });
let pendingPaint = false;
function checkReady() {
if (pendingPaint || !metrics.reads.some((read) => read.transactionMs != null)) return;
if (!document.querySelector(".toolbox-page") || document.querySelectorAll(".toolbox-card").length !== count) return;
pendingPaint = true;
observer.disconnect();
requestAnimationFrame(() => requestAnimationFrame(() => { metrics.readyMs = performance.now(); }));
}
const getAll = IDBObjectStore.prototype.getAll;
IDBObjectStore.prototype.getAll = function (...args) {
const start = performance.now();
const request = getAll.apply(this, args);
if (this.name === "modules") {
const read = { startMs: start };
metrics.reads.push(read);
request.addEventListener("success", () => {
read.requestMs = performance.now() - start;
read.entries = request.result.length;
});
request.addEventListener("error", () => { read.error = request.error?.message; });
this.transaction.addEventListener("abort", () => { read.error = this.transaction.error?.message; });
this.transaction.addEventListener("complete", () => {
read.transactionMs = performance.now() - start;
checkReady();
});
}
return request;
};
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) metrics.longTasks.push({ startMs: entry.startTime, durationMs: entry.duration });
}).observe({ type: "longtask", buffered: true });
}, expectedCards);
}
const server = await preview({ configFile: false, root: "website", preview: { host: "127.0.0.1", port: 4173, strictPort: true } });
try {
await mkdir(".local/performance", { recursive: true });
const report = { date: new Date().toISOString(), platform: platform(), cpu: cpus()[0]?.model, repetitions, scenarios: [] };
for (const scenario of scenarios.filter((scenario) => !requested.length || requested.includes(scenario.name))) {
const profilePath = await mkdtemp(".local/performance/profile-");
let context;
try {
context = await chromium.launchPersistentContext(profilePath, { headless: true, baseURL: "http://127.0.0.1:4173", viewport: { width: 1440, height: 1000 } });
report.browser = context.browser().version();
const seedPage = await context.newPage();
const dataset = await seed(seedPage, scenario);
console.log(`Seeded ${scenario.name}: ${dataset.imageCount} images`);
await seedPage.close();
const page = await context.newPage();
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await instrument(page, scenario.toolboxes);
const cdp = await context.newCDPSession(page);
await cdp.send("Performance.enable");
const samples = [];
let previousTimeOrigin;
// Une navigation de chauffe exclut le premier chargement des bundles des cinq mesures.
for (let iteration = 0; iteration <= repetitions; iteration += 1) {
if (iteration === 0) await page.goto("/#/toolboxes");
else await page.reload();
try {
await page.waitForFunction(() => window.toolboxProfile?.readyMs != null);
} catch (error) {
console.error(scenario.name, iteration, errors, JSON.stringify(await page.evaluate(() => ({
metrics: window.toolboxProfile, cards: document.querySelectorAll(".toolbox-card").length,
text: document.body.innerText
})), null, 2));
throw error;
}
const sample = await page.evaluate(() => ({ ...window.toolboxProfile, cards: document.querySelectorAll(".toolbox-card").length }));
assert.notEqual(sample.timeOrigin, previousTimeOrigin, "Every sample must load a new document");
previousTimeOrigin = sample.timeOrigin;
assert.equal(sample.reads.length, 1, "Production must perform one initial modules read");
assert.equal(sample.reads[0].entries, dataset.moduleCount);
assert.equal(sample.cards, scenario.toolboxes);
assert.deepEqual(errors, []);
await cdp.send("HeapProfiler.collectGarbage");
const { metrics } = await cdp.send("Performance.getMetrics");
sample.jsHeapBytes = metrics.find((metric) => metric.name === "JSHeapUsedSize").value;
sample.longTasks = sample.longTasks.filter((task) => task.startMs < sample.readyMs);
if (iteration > 0) samples.push(sample);
}
const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
const summary = {
readyMedianMs: median(samples.map((sample) => sample.readyMs)),
readyMaxMs: Math.max(...samples.map((sample) => sample.readyMs)),
readMedianMs: median(samples.map((sample) => sample.reads[0].transactionMs)),
heapMedianMiB: median(samples.map((sample) => sample.jsHeapBytes / 1024 ** 2)),
longestTaskMs: Math.max(0, ...samples.flatMap((sample) => sample.longTasks.map((task) => task.durationMs)))
};
report.scenarios.push({ ...scenario, ...dataset, summary, samples });
console.log(scenario.name, JSON.stringify({ mediaMiB: dataset.mediaBytes / 1024 ** 2, ...summary }));
} finally {
await context?.close();
await rm(profilePath, { recursive: true, force: true });
}
}
await writeFile(".local/performance/toolbox-loading.json", `${JSON.stringify(report, null, 2)}\n`);
console.log("Report: .local/performance/toolbox-loading.json");
} finally {
await new Promise((resolve, reject) => server.httpServer.close((error) => error ? reject(error) : resolve()));
}

View file

@ -1,4 +1,4 @@
// Rôle : vérifie les invariants statiques du shell, du routing et des overlays. // Rôle : conserve les conventions éditoriales et les assets du shell non couverts par les parcours navigateur.
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
@ -6,67 +6,32 @@ import assert from "node:assert/strict";
test("vite entrypoint and app shell are wired", async () => { test("vite entrypoint and app shell are wired", async () => {
const html = await readFile("website/index.html", "utf8"); const html = await readFile("website/index.html", "utf8");
const source = await readFile("website/src/main.jsx", "utf8"); const source = await readFile("website/src/main.jsx", "utf8");
const appDataHook = await readFile("website/src/hooks/useAppData.js", "utf8");
const gameFiltersHook = await readFile("website/src/hooks/useGameFilters.js", "utf8"); const gameFiltersHook = await readFile("website/src/hooks/useGameFilters.js", "utf8");
const shell = await readFile("website/src/components/Shell.jsx", "utf8"); const shell = await readFile("website/src/components/Shell.jsx", "utf8");
const overlays = await readFile("website/src/components/AppOverlays.jsx", "utf8"); const overlays = await readFile("website/src/components/AppOverlays.jsx", "utf8");
const modals = await readFile("website/src/components/ToolboxModals.jsx", "utf8");
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8"); const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
const richText = await readFile("website/src/components/RichText.jsx", "utf8");
const homePage = await readFile("website/src/pages/HomePage.jsx", "utf8"); const homePage = await readFile("website/src/pages/HomePage.jsx", "utf8");
const homeOnboarding = await readFile("website/src/pages/HomeOnboardingSection.jsx", "utf8"); const homeOnboarding = await readFile("website/src/pages/HomeOnboardingSection.jsx", "utf8");
const aboutPage = await readFile("website/src/pages/AboutPage.jsx", "utf8"); const aboutPage = await readFile("website/src/pages/AboutPage.jsx", "utf8");
const libraryPage = await readFile("website/src/pages/LibraryPage.jsx", "utf8");
const moduleRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8");
const hashRouter = await readFile("website/src/router/hashRouter.js", "utf8");
const routeContent = await readFile("website/src/router/RouteContent.jsx", "utf8"); const routeContent = await readFile("website/src/router/RouteContent.jsx", "utf8");
const bodyScrollLock = await readFile("website/src/utils/bodyScrollLock.js", "utf8");
assert.match(html, /<div id="app"><\/div>/); assert.match(html, /<div id="app"><\/div>/);
assert.match(html, /\/favicon\.ico/); assert.match(html, /\/favicon\.ico/);
assert.match(html, /\/src\/main\.jsx/); assert.match(html, /\/src\/main\.jsx/);
assert.match(source, /styles\/main\.scss/); assert.match(source, /styles\/main\.scss/);
assert.match(source, /components\/Shell\.jsx/);
assert.match(source, /components\/AppOverlays\.jsx/);
assert.match(source, /router\/RouteContent\.jsx/);
assert.match(source, /hooks\/useAppData\.js/);
assert.match(source, /hooks\/useGameFilters\.js/); assert.match(source, /hooks\/useGameFilters\.js/);
assert.doesNotMatch(source, /DEFAULT_SITE_CONTENT/); assert.doesNotMatch(source, /DEFAULT_SITE_CONTENT/);
assert.match(appDataHook, /export function useAppData/);
assert.match(appDataHook, /\/data\/site\.json/);
assert.match(appDataHook, /setSiteContentError/);
assert.match(gameFiltersHook, /export function useGameFilters/); assert.match(gameFiltersHook, /export function useGameFilters/);
assert.match(hashRouter, /export function useHashRoute/);
assert.match(hashRouter, /export function navigate/);
assert.match(routeContent, /export function RouteContent/);
assert.match(routeContent, /pages\/HomePage\.jsx/);
assert.match(routeContent, /pages\/AboutPage\.jsx/); assert.match(routeContent, /pages\/AboutPage\.jsx/);
assert.match(routeContent, /pages\/LibraryPage\.jsx/);
assert.match(routeContent, /route === "\/library"/);
assert.match(routeContent, /features\/games\/GameRoute\.jsx/); assert.match(routeContent, /features\/games\/GameRoute\.jsx/);
assert.match(shell, /export function Shell/);
assert.doesNotMatch(shell, /content\.navigation\.library/); assert.doesNotMatch(shell, /content\.navigation\.library/);
assert.match(shell, /sidebar-about-link/); assert.match(shell, /sidebar-about-link/);
assert.match(shell, /dragon-sidebar\.png/); assert.match(shell, /dragon-sidebar\.png/);
assert.match(shell, /ImportButton/); assert.match(shell, /ImportButton/);
assert.match(overlays, /export function AppOverlays/);
assert.match(overlays, /\.\/ToolboxModals\.jsx/);
assert.match(overlays, /\.\/ImageViewer\.jsx/); assert.match(overlays, /\.\/ImageViewer\.jsx/);
assert.match(overlays, /uid\("marker"\)/); assert.match(overlays, /uid\("marker"\)/);
assert.match(modals, /export function ConfirmModal/);
assert.match(modals, /export function CreateToolboxModal/);
assert.match(modals, /export function LinkToolboxModal/);
assert.match(modals, /export function NotificationToast/);
assert.match(modals, /durationMs = 120000/);
assert.match(modals, /setTimeout\(\(\) => onClose\(id\), durationMs\)/);
assert.match(source, /SHORT_NOTIFICATION_DURATION_MS = 5000/);
assert.match(source, /getNotificationDuration/);
assert.match(modals, /useModalScrollLock/);
assert.match(imageViewer, /export function ImageViewer/); assert.match(imageViewer, /export function ImageViewer/);
assert.match(imageViewer, /function openImageInNewTab/); assert.match(imageViewer, /function openImageInNewTab/);
assert.match(richText, /value\.split\("\\n"\)/);
assert.match(richText, /<br key=/);
assert.match(homePage, /export function HomePage/);
assert.match(homePage, /HomeOnboardingSection/); assert.match(homePage, /HomeOnboardingSection/);
assert.match(homePage, /import\.meta\.env\.DEV \|\| toolboxes\.length === 0/); assert.match(homePage, /import\.meta\.env\.DEV \|\| toolboxes\.length === 0/);
assert.match(homePage, /setCreateModal\(\{ tutorial: true \}\)/); assert.match(homePage, /setCreateModal\(\{ tutorial: true \}\)/);
@ -82,16 +47,6 @@ test("vite entrypoint and app shell are wired", async () => {
assert.match(aboutPage, /siteContent\.home\.onboarding/); assert.match(aboutPage, /siteContent\.home\.onboarding/);
assert.match(aboutPage, /module-icon-\$\{group\.icon \|\| "notepad"\}/); assert.match(aboutPage, /module-icon-\$\{group\.icon \|\| "notepad"\}/);
assert.match(aboutPage, /about-tool-examples/); assert.match(aboutPage, /about-tool-examples/);
assert.match(libraryPage, /export function LibraryPage/);
assert.match(libraryPage, /LIBRARY_TOOLBOX_ID/);
assert.match(libraryPage, /toolboxModuleComponents\.jsx/);
assert.match(moduleRegistry, /export const TOOLBOX_MODULE_LOADERS/);
assert.match(moduleRegistry, /taskPlanner: \(\) => import\("\.\/modules\/TaskPlannerModule\.jsx"\)/);
assert.match(moduleRegistry, /equipmentPlanner: \(\) => import\("\.\/modules\/EquipmentPlannerModule\.jsx"\)/);
assert.match(libraryPage, /normalizeEquipmentPlannerData/);
assert.match(libraryPage, /summarizeEquipmentPlannerData/);
assert.match(bodyScrollLock, /modalLocks/);
assert.match(bodyScrollLock, /is-modal-open/);
}); });
test("project documentation tracks storage and feature updates", async () => { test("project documentation tracks storage and feature updates", async () => {

View file

@ -34,7 +34,6 @@ test("toolbox storage, cards and pages are wired", async () => {
const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8"); const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8");
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8"); const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8"); const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8");
const inlineEditHook = await readFile("website/src/hooks/useInlineEdit.js", "utf8");
const draftFormHook = await readFile("website/src/hooks/useDraftForm.js", "utf8"); const draftFormHook = await readFile("website/src/hooks/useDraftForm.js", "utf8");
assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/); assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/);
@ -124,15 +123,9 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(toolboxPages, /setDrawerGameId\(""\)/); assert.match(toolboxPages, /setDrawerGameId\(""\)/);
assert.match(toolboxPages, /moduleColumns/); assert.match(toolboxPages, /moduleColumns/);
assert.match(toolboxPages, /normalizeCombosData/); assert.match(toolboxPages, /normalizeCombosData/);
assert.match(toolboxPages, /function ToolboxView/);
assert.match(toolboxPages, /GuidedTooltipTour/);
assert.match(toolboxPages, /data-toolbox-tutorial-target/);
assert.match(guidedTooltipTour, /document\.querySelector/);
assert.match(guidedTooltipTour, /--tutorial-arrow-left/); assert.match(guidedTooltipTour, /--tutorial-arrow-left/);
assert.match(guidedTooltipTour, /--tutorial-arrow-top/); assert.match(guidedTooltipTour, /--tutorial-arrow-top/);
assert.match(guidedTooltipTour, /data-placement/); assert.match(guidedTooltipTour, /data-placement/);
assert.match(toolboxPages, /toolbox\.tutorial === true/);
assert.match(toolboxPages, /tutorial: false/);
assert.doesNotMatch(toolboxPages, /usePointerReorder/); assert.doesNotMatch(toolboxPages, /usePointerReorder/);
assert.match(storageQuota, /export function StorageQuota/); assert.match(storageQuota, /export function StorageQuota/);
assert.match(storageQuota, /role="progressbar"/); assert.match(storageQuota, /role="progressbar"/);
@ -148,20 +141,13 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/); assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/);
assert.match(groupedReorderHook, /data-reorder-orientation/); assert.match(groupedReorderHook, /data-reorder-orientation/);
assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/); assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/);
assert.match(inlineEditHook, /export function useInlineEdit/);
assert.match(inlineEditHook, /skipCommitRef/);
assert.match(inlineEditHook, /blurOnEscape/);
assert.match(inlineEditHook, /commitOnEnter/);
assert.match(inlineEditHook, /getContentEditableProps/);
assert.match(draftFormHook, /export function useDraftForm/); assert.match(draftFormHook, /export function useDraftForm/);
assert.match(draftFormHook, /getFieldProps/); assert.match(draftFormHook, /getFieldProps/);
assert.match(draftFormHook, /handleSubmit/); assert.match(draftFormHook, /handleSubmit/);
assert.match(draftFormHook, /preventDefault/); assert.match(draftFormHook, /preventDefault/);
assert.match(toolboxCoverEditor, /createImageBitmap/);
assert.match(toolboxCoverEditor, /image\/webp/); assert.match(toolboxCoverEditor, /image\/webp/);
assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/); assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/);
assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/); assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/);
assert.match(toolboxCoverEditor, /getCoverCropSourceRect/);
assert.doesNotMatch(toolboxCoverEditor, /react-easy-crop|react-image-crop/); assert.doesNotMatch(toolboxCoverEditor, /react-easy-crop|react-image-crop/);
}); });