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.
414 lines
20 KiB
JavaScript
414 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ObsiGate — JSDOM tests for the spreadsheet menus (ROADMAP #179, #185).
|
|
*
|
|
* 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 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
|
|
*/
|
|
|
|
import { strict as assert } from "node:assert";
|
|
import { JSDOM } from "jsdom";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import path from "node:path";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
|
|
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
|
|
const dom = new JSDOM(
|
|
`<!DOCTYPE html><html><body><div id="content-area"></div></body></html>`,
|
|
{ url: "http://localhost/", pretendToBeVisual: true }
|
|
);
|
|
|
|
const w = dom.window;
|
|
globalThis.window = w;
|
|
globalThis.document = w.document;
|
|
globalThis.DOMParser = w.DOMParser;
|
|
globalThis.HTMLElement = w.HTMLElement;
|
|
globalThis.Element = w.Element;
|
|
globalThis.Node = w.Node;
|
|
globalThis.Event = w.Event;
|
|
globalThis.FocusEvent = w.FocusEvent;
|
|
globalThis.KeyboardEvent = w.KeyboardEvent;
|
|
globalThis.MouseEvent = w.MouseEvent;
|
|
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")
|
|
).href;
|
|
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;
|
|
let passCount = 0;
|
|
async function test(name, fn) {
|
|
testCount++;
|
|
closeContextMenu();
|
|
document.body.querySelectorAll(".tmp-menu").forEach((m) => m.remove());
|
|
try {
|
|
await fn();
|
|
passCount++;
|
|
console.log(` ✓ ${name}`);
|
|
} catch (e) {
|
|
console.error(` ✗ ${name}`);
|
|
console.error(e);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const itemsOf = () => [
|
|
{ icon: "scissors", label: "Cut" },
|
|
{ icon: "copy", label: "Copy", disabled: true },
|
|
{ separator: true },
|
|
{ icon: "trash-2", label: "Delete", danger: true, onClick: () => {} },
|
|
];
|
|
|
|
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
await test("entries render their icon, label, separator and danger style", () => {
|
|
const menu = openContextMenu(10, 10, itemsOf());
|
|
const btns = [...menu.querySelectorAll(".xlsx-context-item")];
|
|
assert.equal(btns.length, 3);
|
|
assert.equal(btns[0].querySelector("i").getAttribute("data-lucide"), "scissors");
|
|
assert.equal(btns[0].querySelector(".xlsx-menu-label").textContent, "Cut");
|
|
assert.equal(btns[1].disabled, true);
|
|
assert.ok(btns[2].classList.contains("xlsx-context-danger"));
|
|
assert.ok(menu.querySelector(".xlsx-context-sep"), "separator rendered");
|
|
assert.equal(menu.getAttribute("role"), "menu");
|
|
});
|
|
|
|
await test("labels are text, never HTML (XSS-safe)", () => {
|
|
const menu = openContextMenu(10, 10, [{ icon: "copy", label: "<img src=x onerror=1>" }]);
|
|
assert.equal(menu.querySelector("img"), null);
|
|
assert.equal(
|
|
menu.querySelector(".xlsx-menu-label").textContent,
|
|
"<img src=x onerror=1>"
|
|
);
|
|
});
|
|
|
|
await test("opening focuses the first enabled entry", () => {
|
|
const menu = openContextMenu(10, 10, itemsOf());
|
|
const first = menu.querySelectorAll(".xlsx-context-item")[0];
|
|
assert.equal(document.activeElement, first);
|
|
});
|
|
|
|
// ── Keyboard navigation ─────────────────────────────────────────────────────
|
|
await test("ArrowDown / ArrowUp / Home / End walk through enabled entries", () => {
|
|
const menu = openContextMenu(10, 10, itemsOf());
|
|
const btns = [...menu.querySelectorAll(".xlsx-context-item")];
|
|
const key = (k) =>
|
|
menu.dispatchEvent(new w.KeyboardEvent("keydown", { key: k, bubbles: true }));
|
|
key("ArrowDown"); // Cut (0, disabled Copy skipped) -> Delete (2)
|
|
assert.equal(document.activeElement, btns[2]);
|
|
key("ArrowDown"); // wraps to Cut
|
|
assert.equal(document.activeElement, btns[0]);
|
|
key("ArrowUp"); // back to Delete
|
|
assert.equal(document.activeElement, btns[2]);
|
|
key("Home");
|
|
assert.equal(document.activeElement, btns[0]);
|
|
key("End");
|
|
assert.equal(document.activeElement, btns[2]);
|
|
});
|
|
|
|
// ── Dismissal ───────────────────────────────────────────────────────────────
|
|
await test("Escape closes the menu", () => {
|
|
openContextMenu(10, 10, itemsOf());
|
|
assert.ok(isContextMenuOpen());
|
|
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
assert.ok(!isContextMenuOpen());
|
|
assert.equal(document.querySelector(".xlsx-context-menu"), null);
|
|
});
|
|
|
|
await test("outside click closes, inside click does not", async () => {
|
|
const menu = openContextMenu(10, 10, itemsOf());
|
|
await sleep(300); // past the long-press click guard
|
|
menu.querySelector(".xlsx-context-item").dispatchEvent(
|
|
new w.MouseEvent("click", { bubbles: true })
|
|
);
|
|
assert.ok(!isContextMenuOpen(), "item click closes");
|
|
openContextMenu(10, 10, itemsOf());
|
|
await sleep(300);
|
|
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
|
|
assert.ok(!isContextMenuOpen(), "outside click closes");
|
|
});
|
|
|
|
await test("focus leaving the menu closes it, focus inside keeps it", () => {
|
|
const menu = openContextMenu(10, 10, itemsOf());
|
|
const [first, , last] = menu.querySelectorAll(".xlsx-context-item");
|
|
first.dispatchEvent(
|
|
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: last })
|
|
);
|
|
assert.ok(isContextMenuOpen(), "focus moving inside keeps the menu");
|
|
const outside = document.createElement("button");
|
|
document.body.appendChild(outside);
|
|
last.dispatchEvent(
|
|
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside })
|
|
);
|
|
assert.ok(!isContextMenuOpen(), "focus moving outside closes the menu");
|
|
outside.remove();
|
|
});
|
|
|
|
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) ──────────────────────────
|
|
function tmpMenu(toggle) {
|
|
const menu = document.createElement("div");
|
|
menu.className = "tmp-menu";
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.textContent = "Action";
|
|
menu.appendChild(b);
|
|
(toggle.closest("div") || document.body).appendChild(menu);
|
|
return menu;
|
|
}
|
|
|
|
await test("trackDismissable: toggle exempt, outside closes with onClose", async () => {
|
|
const host = document.createElement("div");
|
|
const toggle = document.createElement("button");
|
|
host.appendChild(toggle);
|
|
document.body.appendChild(host);
|
|
const menu = tmpMenu(toggle);
|
|
let closed = 0;
|
|
const dismiss = trackDismissable(menu, { toggle, onClose: () => { closed++; menu.remove(); } });
|
|
toggle.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
|
|
assert.equal(closed, 0, "toggle click does not dismiss");
|
|
assert.ok(menu.isConnected);
|
|
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
|
|
assert.equal(closed, 1, "outside click dismisses once");
|
|
assert.ok(!menu.isConnected);
|
|
dismiss(); // idempotent manual close
|
|
assert.equal(closed, 1);
|
|
host.remove();
|
|
});
|
|
|
|
await test("trackDismissable: Escape and focus loss dismiss", () => {
|
|
const toggle = document.createElement("button");
|
|
document.body.appendChild(toggle);
|
|
const menu = tmpMenu(toggle);
|
|
let closed = 0;
|
|
trackDismissable(menu, { toggle, onClose: () => { closed++; menu.remove(); } });
|
|
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
assert.equal(closed, 1, "Escape dismisses");
|
|
assert.ok(!menu.isConnected);
|
|
|
|
const menu2 = tmpMenu(toggle);
|
|
let closed2 = 0;
|
|
trackDismissable(menu2, { toggle, onClose: () => { closed2++; menu2.remove(); } });
|
|
const outside = document.createElement("input");
|
|
document.body.appendChild(outside);
|
|
menu2.querySelector("button").dispatchEvent(
|
|
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside })
|
|
);
|
|
assert.equal(closed2, 1, "focus loss dismisses");
|
|
outside.remove();
|
|
toggle.remove();
|
|
});
|
|
|
|
// ── 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 + 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 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"],
|
|
);
|
|
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`);
|
|
process.exit(process.exitCode || 0);
|