feat: éditeur tableur — grille A → Z par blocs, barre de menus Sheets, panneau de couleurs, création .xlsx (#180 → #186)
Restaure et termine le lot tableur resté non committé. Il n'existait que dans un `git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js, color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le stash, créé avant le commit BUG-108, n'a jamais été restauré. #180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint), grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu Fichier au niveau des onglets, barre épinglée pleine largeur. #181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans quitter contenteditable, double-clic / F2 / première frappe qui remplace, Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts. #182 Désélection fiable (clic simple sans dépendre du focus), contours de plage non empilés (sélecteur de classes sans point), couleurs texte/fond sur une plage, clic droit qui préserve la sélection multiple. #183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD, PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était rejetée par normHex). #184 Peinture de format complète (toutes propriétés, source sans format = réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de cellules `ref → td`, court-circuits formule/styles, marqueur data-wired. #185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36 annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème. #186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC (openpyxl) au lieu d'une charge utile texte illisible. Corrections trouvées en restaurant et en exerçant le lot : - fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ; - sorties Markdown / HTML / Imprimer de la barre de menus inertes (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ; - collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ; - curseur col/row-resize absent quand le pointeur est sur la table elle-même ; - l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown, HTML, impression) ; - clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au seul appui long tactile) ; - une entrée indisponible laissait la barre de menus ouverte. Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ; 33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19, xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer 19/19.
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM tests for the spreadsheet menus polish (ROADMAP #179).
|
||||
* ObsiGate — JSDOM tests for the spreadsheet menus (ROADMAP #179, #185).
|
||||
*
|
||||
* Covers frontend/js/xlsx/context-menu.js and frontend/js/xlsx/command-bar.js:
|
||||
* Covers frontend/js/xlsx/context-menu.js, command-bar.js and menus.js:
|
||||
* - every grid-menu entry renders its representative Lucide icon + safe label;
|
||||
* - the grid menu closes on Escape / outside click / focus loss / scroll /
|
||||
* resize, and offers arrow-key navigation with autofocus on open;
|
||||
* - trackDismissable() gives Structure / Mise en forme / Export the same
|
||||
* dismissal contract (toggle exempt, custom onClose);
|
||||
* - the ribbon carries the new icons (save, tab "+", export entries).
|
||||
* - the ribbon carries its icons (save, tab "+", gridlines);
|
||||
* - #185: the menu bar lists the ten Google Sheets menus, every entry is
|
||||
* actionable or honestly flagged unavailable, labels come from the catalog,
|
||||
* editing entries disappear on a read-only workbook, and the historical
|
||||
* Fichier ids (#xlsx-file-btn / -menu / #xlsx-download-btn / #xlsx-csv-btn)
|
||||
* survive the move from the sheet strip.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-menus.test.mjs
|
||||
*/
|
||||
@@ -43,6 +48,18 @@ globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
|
||||
|
||||
// #185 — the menu bar renders localized labels, so the real FR catalog has to
|
||||
// be reachable: serve /static/locales/*.json from disk and nothing else.
|
||||
const { readFileSync } = await import("node:fs");
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/static/locales/")) {
|
||||
const file = path.join(REPO_ROOT, "frontend", "locales", "fr.json");
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(readFileSync(file, "utf8")) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}), text: async () => "" };
|
||||
};
|
||||
|
||||
// ── Module under test ───────────────────────────────────────────────────────
|
||||
const ctxMenuUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")
|
||||
@@ -50,9 +67,17 @@ const ctxMenuUrl = pathToFileURL(
|
||||
const cmdBarUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "command-bar.js")
|
||||
).href;
|
||||
const menusUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "menus.js")
|
||||
).href;
|
||||
const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } =
|
||||
await import(ctxMenuUrl);
|
||||
const { buildCommandBar } = await import(cmdBarUrl);
|
||||
const { buildMenuBar, SHEETS_MENUS } = await import(menusUrl);
|
||||
const { initI18n } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href
|
||||
);
|
||||
await initI18n();
|
||||
|
||||
// ── Mini runner ─────────────────────────────────────────────────────────────
|
||||
let testCount = 0;
|
||||
@@ -163,13 +188,32 @@ await test("focus leaving the menu closes it, focus inside keeps it", () => {
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
await test("scroll and resize close the menu", () => {
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(!isContextMenuOpen(), "scroll closes");
|
||||
await test("double close never throws and a late click still runs its action", () => {
|
||||
// Regression: in a real browser, remove() fires focusout synchronously,
|
||||
// which re-entered closeContextMenu — the second remove() threw
|
||||
// NotFoundError and aborted the item's onClick before it ran.
|
||||
let runs = 0;
|
||||
const menu = openContextMenu(10, 10, [{ icon: "copy", label: "Copy", onClick: () => { runs++; } }]);
|
||||
const btn = menu.querySelector(".xlsx-context-item");
|
||||
closeContextMenu();
|
||||
closeContextMenu(); // idempotent: no-op, no throw
|
||||
assert.ok(!isContextMenuOpen());
|
||||
btn.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(runs, 1, "click on the detached entry still runs its action");
|
||||
});
|
||||
|
||||
await test("scroll and resize close the menu", async () => {
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
window.dispatchEvent(new w.Event("resize"));
|
||||
assert.ok(!isContextMenuOpen(), "resize closes");
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
// The smooth-scroll tail of the opening gesture is forgiven…
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(isContextMenuOpen(), "immediate scroll does not close");
|
||||
// …but a later scroll detaches the menu.
|
||||
await sleep(450);
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(!isContextMenuOpen(), "later scroll closes");
|
||||
});
|
||||
|
||||
// ── trackDismissable (Structure / Format / Export) ──────────────────────────
|
||||
@@ -226,26 +270,143 @@ await test("trackDismissable: Escape and focus loss dismiss", () => {
|
||||
toggle.remove();
|
||||
});
|
||||
|
||||
// ── Ribbon icons ────────────────────────────────────────────────────────────
|
||||
await test("the ribbon carries save, tab-plus and export icons", () => {
|
||||
const { actionsHtml, tabs } = buildCommandBar({
|
||||
// ── Ribbon icons (#185: the File menu moved to the menu bar) ────────────────
|
||||
await test("the ribbon carries save, tab-plus and gridline icons", () => {
|
||||
const { actionsHtml, tabs, fileHtml } = buildCommandBar({
|
||||
sheets: [{ name: "F1" }],
|
||||
isCsv: false,
|
||||
readOnly: false,
|
||||
editable: true,
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = tabs + actionsHtml;
|
||||
host.innerHTML = tabs + actionsHtml + fileHtml;
|
||||
const saveIcon = host.querySelector("#xlsx-save-btn i[data-lucide]");
|
||||
assert.ok(saveIcon, "save button has an icon");
|
||||
assert.equal(saveIcon.getAttribute("data-lucide"), "save");
|
||||
const plusIcon = host.querySelector("#xlsx-tab-add i[data-lucide]");
|
||||
assert.ok(plusIcon, "tab + button has an icon");
|
||||
assert.equal(plusIcon.getAttribute("data-lucide"), "plus");
|
||||
const exportIcons = [...host.querySelectorAll("#xlsx-export-menu i[data-lucide]")].map((i) =>
|
||||
i.getAttribute("data-lucide")
|
||||
const gridIcon = host.querySelector("#xs-grid i[data-lucide]");
|
||||
assert.ok(gridIcon, "gridline switch has an icon");
|
||||
assert.equal(gridIcon.getAttribute("data-lucide"), "grid-3x3");
|
||||
// #185 — the sheet strip keeps Save only; the file outputs live in the menu bar.
|
||||
assert.equal(host.querySelector("#xlsx-file-btn"), null, "no file dropdown left on the strip");
|
||||
});
|
||||
|
||||
// ── #185 — the menu bar itself ──────────────────────────────────────────────
|
||||
await test("buildMenuBar renders the ten Sheets menus with their labels", () => {
|
||||
assert.deepEqual(
|
||||
SHEETS_MENUS.map((m) => m.id),
|
||||
["file", "edit", "view", "insert", "format", "data", "tools", "gemini", "extensions", "help"],
|
||||
);
|
||||
assert.deepEqual(exportIcons, ["file-text", "code", "printer"]);
|
||||
const html = buildMenuBar({ editable: true, readOnly: false });
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = html;
|
||||
const menus = [...host.querySelectorAll(".xlsx-menubar > .xlsx-menuwrap")];
|
||||
assert.deepEqual(
|
||||
menus.map((w) => w.querySelector(".xlsx-menubtn").textContent.trim()),
|
||||
["Fichier", "Modifier", "Affichage", "Insertion", "Format", "Données", "Outils", "Gemini", "Extensions", "Aide"],
|
||||
);
|
||||
// Every button controls its own dropdown, hidden until opened.
|
||||
menus.forEach((w) => {
|
||||
const btn = w.querySelector(".xlsx-menubtn");
|
||||
const drop = host.querySelector(`#${btn.getAttribute("aria-controls")}`);
|
||||
assert.ok(drop, `${btn.dataset.xlsxMenu} has a dropdown`);
|
||||
assert.equal(drop.getAttribute("role"), "menu");
|
||||
assert.equal(drop.style.display, "none", "dropdown starts closed");
|
||||
assert.equal(btn.getAttribute("aria-expanded"), "false");
|
||||
});
|
||||
});
|
||||
|
||||
await test("the Fichier menu keeps the historical ids and export markers", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
assert.ok(host.querySelector("#xlsx-file-btn"), "file button id kept");
|
||||
assert.ok(host.querySelector("#xlsx-file-menu"), "file menu id kept");
|
||||
assert.ok(host.querySelector("#xlsx-download-btn"), "download entry kept its id");
|
||||
assert.ok(host.querySelector("#xlsx-csv-btn"), "csv entry kept its id");
|
||||
assert.deepEqual(
|
||||
[...host.querySelectorAll("#xlsx-file-menu [data-xlsx-export]")].map((n) => n.dataset.xlsxExport),
|
||||
["md", "html", "print"],
|
||||
);
|
||||
});
|
||||
|
||||
await test("every menu entry is either actionable or honestly flagged", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const rows = [...host.querySelectorAll(".xlsx-menu-item")];
|
||||
assert.ok(rows.length > 120, `all Sheets entries are listed (${rows.length})`);
|
||||
rows.forEach((row) => {
|
||||
const actionable = row.hasAttribute("data-xlsx-action") || row.hasAttribute("data-xlsx-export");
|
||||
const flagged = row.hasAttribute("data-xlsx-unavailable");
|
||||
assert.ok(actionable || flagged || row.hasAttribute("aria-haspopup"),
|
||||
`"${row.textContent.trim()}" is actionable, a section, or flagged unavailable`);
|
||||
});
|
||||
// A declared action always resolves against the viewer's registry (#185).
|
||||
const ACTIONS = [
|
||||
"share", "download", "export-csv", "rename", "trash", "history", "details", "settings", "print",
|
||||
"undo", "redo", "cut", "copy", "paste", "paste-values", "delete-rows", "delete-cols",
|
||||
"clear-contents", "select-all", "find-replace", "freeze", "freeze-row", "freeze-col",
|
||||
"unfreeze", "toggle-formula-bar", "toggle-grid", "toggle-headers", "dashboard", "zoom",
|
||||
"fullscreen", "insert-rows-above", "insert-rows-below", "insert-cols-left",
|
||||
"insert-cols-right", "insert-cells", "link", "comment", "function", "numfmt", "theme-menu",
|
||||
"bold", "italic", "underline", "strike", "fontsize", "align", "valign",
|
||||
"wrap-on", "wrap-off", "rotate", "clear-format", "banded", "sort-asc", "sort-desc",
|
||||
"sort-reset", "filter-column", "named-ranges", "cleanup-dupes", "cleanup-trim",
|
||||
"split-text", "autocomplete", "a11y", "gemini", "help", "whatsnew", "shortcuts", "report",
|
||||
];
|
||||
const declared = new Set(
|
||||
[...host.querySelectorAll("[data-xlsx-action]")].map((n) => n.dataset.xlsxAction),
|
||||
);
|
||||
ACTIONS.forEach((name) => assert.ok(declared.has(name), `menu declares "${name}"`));
|
||||
// "apply-theme" is deliberately absent: the theme list is built on demand.
|
||||
assert.equal(declared.has("apply-theme"), false, "themes are not hard-coded in the markup");
|
||||
});
|
||||
|
||||
await test("Format ▸ Thème is an empty section the viewer fills on demand (#185)", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const row = host.querySelector('[data-xlsx-menu="format"]')
|
||||
.closest(".xlsx-menuwrap")
|
||||
.querySelector('[data-xlsx-action="theme-menu"]');
|
||||
assert.ok(row, "the Thème section exists");
|
||||
const sub = row.nextElementSibling;
|
||||
assert.equal(sub.className, "xlsx-menu-sub", "it is a section, not a leaf");
|
||||
assert.equal(sub.hidden, true, "collapsed");
|
||||
assert.equal(sub.children.length, 0, "empty until the viewer fills it");
|
||||
assert.equal(row.getAttribute("aria-expanded"), "false");
|
||||
// The next entry is untouched by the empty section.
|
||||
assert.ok(host.querySelector('[data-xlsx-action="bold"]'), "Bold still listed");
|
||||
});
|
||||
|
||||
await test("editing entries disappear on a read-only workbook, view entries stay", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: false, readOnly: true });
|
||||
assert.equal(host.querySelector('[data-xlsx-action="bold"]'), null, "no Bold");
|
||||
assert.equal(host.querySelector('[data-xlsx-action="insert-cells"]'), null, "no Insert cells");
|
||||
assert.equal(host.querySelector('[data-xlsx-action="undo"]'), null, "no Undo");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="copy"]'), "Copy stays available");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="zoom"]'), "Zoom stays available");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="toggle-grid"]'), "Gridlines stay available");
|
||||
});
|
||||
|
||||
await test("menu labels come from the i18n catalog, never from raw text", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const labels = [...host.querySelectorAll(".xlsx-menu-label")].map((n) => n.textContent);
|
||||
assert.ok(labels.length > 100);
|
||||
assert.equal(labels.some((l) => /xlsx\./.test(l)), false, "no unresolved key is rendered");
|
||||
assert.ok(labels.includes("Nouveau"), "Google Sheets' own wording is kept");
|
||||
assert.ok(labels.includes("Couleurs alternées"));
|
||||
assert.ok(labels.includes("Diviser le texte en colonnes"));
|
||||
});
|
||||
|
||||
await test("a sheet name or cell value cannot leak HTML into a menu label", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
host.querySelectorAll(".xlsx-menu-label").forEach((n) => {
|
||||
assert.equal(n.querySelector("img, script"), null, "labels stay text nodes");
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} passed`);
|
||||
|
||||
Reference in New Issue
Block a user