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.
177 lines
5.6 KiB
JavaScript
177 lines
5.6 KiB
JavaScript
#!/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);
|