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:
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — unit tests for the spreadsheet formula engine
|
||||
* (frontend/js/xlsx/formula.js). Pure logic, no DOM needed.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-formula.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
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, "..", "..");
|
||||
|
||||
const modUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "formula.js")
|
||||
).href;
|
||||
const {
|
||||
isFormulaText, parseFormula, evaluate, createScope, displayValue,
|
||||
colToIndex, indexToCol, parseA1,
|
||||
} = await import(modUrl);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (e) {
|
||||
console.error(` ✗ ${name}`);
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate `text` against a fake sheet {ref: raw}. */
|
||||
function run(text, cells = {}, locale = "en") {
|
||||
const scope = createScope({
|
||||
readRaw: (_sheet, ref) => {
|
||||
const hit = Object.entries(cells).find(
|
||||
([k]) => k.toUpperCase() === String(ref).toUpperCase()
|
||||
);
|
||||
return hit ? hit[1] : null;
|
||||
},
|
||||
sheet: "F",
|
||||
locale,
|
||||
});
|
||||
return displayValue(evaluate(parseFormula(text), scope), locale);
|
||||
}
|
||||
|
||||
await test("detects formula text", () => {
|
||||
assert.ok(isFormulaText("=1+1"));
|
||||
assert.ok(isFormulaText(" @A1 "));
|
||||
assert.ok(!isFormulaText("hello"));
|
||||
assert.ok(!isFormulaText("="));
|
||||
});
|
||||
|
||||
await test("refs helpers", () => {
|
||||
assert.equal(colToIndex("A"), 1);
|
||||
assert.equal(colToIndex("Z"), 26);
|
||||
assert.equal(colToIndex("AA"), 27);
|
||||
assert.equal(indexToCol(28), "AB");
|
||||
assert.deepEqual(parseA1("$B$12"), { col: 2, row: 12 });
|
||||
assert.equal(parseA1("ZZZ"), null);
|
||||
});
|
||||
|
||||
await test("arithmetic and Excel precedence (-2^2 = 4)", () => {
|
||||
assert.equal(run("=1+2*3"), "7");
|
||||
assert.equal(run("=(1+2)*3"), "9");
|
||||
assert.equal(run("=2^3"), "8");
|
||||
assert.equal(run("=-2^2"), "4");
|
||||
assert.equal(run("=10/4"), "2.5");
|
||||
assert.equal(run("=50%"), "0.5");
|
||||
assert.equal(run("=5*10%"), "0.5");
|
||||
});
|
||||
|
||||
await test("comparisons and concatenation", () => {
|
||||
assert.equal(run("=1+1=2"), "TRUE");
|
||||
assert.equal(run('="a"&"b"'), "ab");
|
||||
assert.equal(run("=3>2"), "TRUE");
|
||||
assert.equal(run("=1<>1"), "FALSE");
|
||||
});
|
||||
|
||||
await test("cell refs and ranges", () => {
|
||||
const cells = { A1: 2, A2: 4, B1: "x" };
|
||||
assert.equal(run("=A1*3", cells), "6");
|
||||
assert.equal(run("=SOMME(A1:A2)", cells), "6");
|
||||
assert.equal(run("=SUM(A1:A2)", cells), "6");
|
||||
assert.equal(run("=MOYENNE(A1:A2)", cells), "3");
|
||||
assert.equal(run("=A1&B1", cells), "2x");
|
||||
});
|
||||
|
||||
await test("French decimal comma with ; separator", () => {
|
||||
assert.equal(run("=1,5+1", {}, "fr"), "2.5");
|
||||
assert.equal(run("=SOMME(1;2;3)"), "6");
|
||||
assert.equal(run("=SI(1>2;10;20)"), "20");
|
||||
});
|
||||
|
||||
await test("functions FR/EN", () => {
|
||||
assert.equal(run("=MAX(1;5;3)"), "5");
|
||||
assert.equal(run("=MIN(1;5;3)"), "1");
|
||||
assert.equal(run("=ABS(-4)"), "4");
|
||||
assert.equal(run("=ARRONDI(2.456;2)"), "2.46");
|
||||
assert.equal(run("=RACINE(9)"), "3");
|
||||
assert.equal(run("=PUISSANCE(2;10)"), "1024");
|
||||
assert.equal(run("=NB(1;\"x\";3)"), "2");
|
||||
assert.equal(run("=NBVAL(1;\"x\";3)"), "3");
|
||||
assert.equal(run("=NBCAR(\"héllo\")"), "5");
|
||||
assert.equal(run("=MAJUSCULES(\"abc\")"), "ABC");
|
||||
assert.equal(run("=GAUCHE(\"hello\";2)"), "he");
|
||||
assert.equal(run("=DROITE(\"hello\";2)"), "lo");
|
||||
assert.equal(run("=STXT(\"hello\";2;3)"), "ell");
|
||||
assert.equal(run("=ET(1;1)"), "TRUE");
|
||||
assert.equal(run("=OU(0;1)"), "TRUE");
|
||||
assert.equal(run("=NON(1)"), "FALSE");
|
||||
assert.equal(run('=SIERREUR(1/0;"raté")'), "raté");
|
||||
assert.equal(run("=MEDIANE(3;1;2)"), "2");
|
||||
assert.equal(run("=PRODUIT(2;3;4)"), "24");
|
||||
assert.equal(run("=MOD(7;3)"), "1");
|
||||
assert.equal(run("=ENT(2.9)"), "2");
|
||||
});
|
||||
|
||||
await test("nested formulas and chained refs", () => {
|
||||
const cells = { A1: "=B1*2", B1: 21 };
|
||||
assert.equal(run("=A1+1", cells), "43");
|
||||
});
|
||||
|
||||
await test("errors surface as localized codes", () => {
|
||||
assert.equal(run("=1/0"), "#DIV/0!");
|
||||
assert.equal(run("=1/0", {}, "fr"), "#DIV/0!");
|
||||
assert.equal(run("=NOSUCHFN(1)"), "#NAME?");
|
||||
assert.equal(run("=NOSUCHFN(1)", {}, "fr"), "#NOM?");
|
||||
assert.equal(run('="a"+1'), "#VALUE!");
|
||||
assert.equal(run('="a"+1', {}, "fr"), "#VALEUR!");
|
||||
assert.equal(run("=A99999999"), "#REF!");
|
||||
});
|
||||
|
||||
await test("cycles are detected", () => {
|
||||
const cells = { A1: "=B1", B1: "=A1" };
|
||||
assert.equal(run("=A1", cells), "#CIRC!");
|
||||
assert.equal(run("=A1", cells, "fr"), "#CYCLE!");
|
||||
});
|
||||
|
||||
await test("booleans localize", () => {
|
||||
assert.equal(run("=VRAI"), "TRUE");
|
||||
assert.equal(run("=VRAI", {}, "fr"), "VRAI");
|
||||
assert.equal(run("=TRUE"), "TRUE");
|
||||
assert.equal(run("=1=1", {}, "fr"), "VRAI");
|
||||
});
|
||||
|
||||
await test("HYPERLINK yields a link value", async () => {
|
||||
const scope = createScope({ readRaw: () => null, sheet: "F", locale: "en" });
|
||||
const v = evaluate(parseFormula('=LIEN("https://x.test";"ici")'), scope);
|
||||
assert.equal(v.t, "link");
|
||||
assert.equal(v.url, "https://x.test");
|
||||
assert.equal(v.label, "ici");
|
||||
assert.equal(displayValue(v), "ici");
|
||||
});
|
||||
|
||||
await test("garbage is rejected by the parser", () => {
|
||||
assert.throws(() => parseFormula("=1+"), Error);
|
||||
assert.throws(() => parseFormula("=SUM("), Error);
|
||||
assert.throws(() => parseFormula("=(1"), Error);
|
||||
});
|
||||
|
||||
await test("quoted and absolute refs", () => {
|
||||
const cells = { A1: 5 };
|
||||
assert.equal(run("=$A$1+1", cells), "6");
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} passed`);
|
||||
process.exit(process.exitCode || 0);
|
||||
@@ -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`);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user