Files
ObsiGate/tests/frontend/xlsx-viewer.test.mjs
T
bruno 6ba04c4381
CI / lint (push) Successful in 2m29s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 4m1s
CI / build (push) Successful in 1m37s
CI / e2e (push) Successful in 14m47s
feat: troncature annoncee et lecture par fenetres des tableurs #153
- BUG-090 (#153 A8) : render_sheets() expose total_rows/total_cols,
  max_rows/max_cols et truncated ; la visionneuse affiche un bandeau
  « Feuille tronquée » (i18n FR/EN) au lieu de couper en silence, et la
  ligne d'en-têtes devient sticky (top:auto sur les numéros de ligne).
- #153 A9 : GET /api/file/{vault}/xlsx/sheet?sheet&offset&limit sert une
  fenêtre de 1 à 1000 lignes avec les vraies coordonnées A1, has_more de
  pagination et valeurs calculées A12 ; 404 feuille inconnue, 415 non-xlsx.
- Tests : TestXlsxTruncationNotice (4) + TestXlsxSheetWindow (11) avec
  contre-preuves, xlsx-viewer.test.mjs 14/14, E2E 7/7 (fixture
  sample-xlsx-large.xlsx 520 lignes), suite 1417 passed / 6 skipped,
  ruff/mypy 0, i18n parity.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 10:38:24 -04:00

318 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* ObsiGate — JSDOM integration tests for the Excel viewer (ROADMAP #153 P0).
*
* Loads the real viewer.js module and drives renderXlsxViewer():
* - A1 : `xlsx_lossy_features` renders a warning banner; a save on such a
* workbook gets 409 `xlsx_lossy_content`, asks for confirmation and
* retries with `force: true` (or gives up when refused);
* - A4 : the f(x) toggle flips `allow_formula` in the save payload.
* - A8 : a sheet bigger than the render caps shows the truncation notice.
*
* Usage: node tests/frontend/xlsx-viewer.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import { readFileSync } from "node:fs";
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.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.CustomEvent = w.CustomEvent;
globalThis.MouseEvent = w.MouseEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
// ── fetch / confirm doubles ─────────────────────────────────────────────────
let calls = [];
let nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } });
let confirmAnswer = true;
let confirmCalls = 0;
// Every confirm() prompt is captured so the tests can assert on its text.
let confirmPrompts = [];
const FR = JSON.parse(
readFileSync(path.join(REPO_ROOT, "frontend", "locales", "fr.json"), "utf8")
);
globalThis.fetch = async (url, opts = {}) => {
// The i18n bootstrap fetches the locale files: serve the real FR one so the
// assertions run on the shipped strings, not on raw keys.
if (url.includes("/static/locales/")) {
return { ok: true, status: 200, json: async () => FR };
}
calls.push({ url, body: opts.body ? JSON.parse(opts.body) : null });
const res = nextResponse(url, opts);
return {
ok: res.ok,
status: res.status,
json: async () => res.body,
};
};
globalThis.confirm = (msg) => { confirmCalls++; confirmPrompts.push(msg); return confirmAnswer; };
w.confirm = globalThis.confirm;
// ── Helpers ─────────────────────────────────────────────────────────────────
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
calls = [];
confirmCalls = 0;
confirmPrompts = [];
confirmAnswer = true;
nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } });
try {
await fn();
console.log(` ✓ ${name}`);
passCount++;
} catch (e) {
console.log(` ✗ ${name}`);
console.log(` ${e.message}`);
if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`);
}
}
const { renderXlsxViewer } = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "viewer.js")).href
);
// Load the FR catalog so t() resolves the real strings.
const { initI18n } = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href
);
await initI18n();
const sheetHtml = (value) =>
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td></tr>` +
"</tbody></table></div>";
function mount({ lossy = [], sheet = {} } = {}) {
const area = document.getElementById("content-area");
area.innerHTML = "";
renderXlsxViewer(area, {
vault: "V",
path: "data.xlsx",
is_xlsx: true,
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }],
xlsx_lossy_features: lossy,
});
return area;
}
/** Mark a cell dirty the way a user edit would. */
function editCell(area, ref, text) {
const td = area.querySelector(`td[data-cell="${ref}"]`);
td.textContent = text;
td.dispatchEvent(new w.Event("input", { bubbles: true }));
return td;
}
const lossyError = {
ok: false,
status: 409,
body: {
detail: "Saving this workbook would drop features…",
code: "xlsx_lossy_content",
details: { features: ["slicers"] },
},
};
console.log("\n── xlsx viewer JSDOM integration tests (#153 P0) ──\n");
// ── A1 — warning banner ─────────────────────────────────────────────────────
await test("no banner when the workbook has nothing at risk", () => {
const area = mount();
assert.equal(area.querySelector(".xlsx-warning"), null);
});
await test("banner lists every lossy feature reported by the backend", () => {
const area = mount({ lossy: ["cached_values", "slicers"] });
const banner = area.querySelector(".xlsx-warning");
assert.ok(banner, "banner absent");
const tags = [...banner.querySelectorAll(".xlsx-warning-tag")].map((n) => n.textContent);
assert.equal(tags.length, 2);
assert.ok(tags.includes(FR["xlsx.feature_cached_values"]), tags.join("|"));
assert.ok(tags.includes(FR["xlsx.feature_slicers"]), tags.join("|"));
assert.ok(banner.textContent.includes(FR["xlsx.lossy_title"]));
assert.ok(banner.textContent.includes(FR["xlsx.lossy_hint"]));
});
await test("formula toggle is present and starts unpressed", () => {
const area = mount();
const btn = area.querySelector("#xlsx-formula-btn");
assert.ok(btn);
assert.equal(btn.getAttribute("aria-pressed"), "false");
assert.equal(btn.getAttribute("title"), FR["xlsx.formula_toggle_title"]);
});
// ── Save payload ────────────────────────────────────────────────────────────
await test("save sends one PUT per dirty sheet with the cell map", async () => {
const area = mount();
editCell(area, "A1", "250");
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 0));
assert.equal(calls.length, 1);
assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/save\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.cells, { A1: "250" });
assert.equal(calls[0].body.sheet, "Feuille1");
assert.equal(calls[0].body.force, false);
assert.equal(calls[0].body.allow_formula, false);
});
await test("save button stays disabled when nothing is dirty", async () => {
const area = mount();
const btn = area.querySelector("#xlsx-save-btn");
assert.equal(btn.disabled, true);
btn.click();
await new Promise((r) => setTimeout(r, 0));
assert.equal(calls.length, 0);
});
// ── A4 — formula toggle ─────────────────────────────────────────────────────
await test("f(x) toggle flips allow_formula on the next save", async () => {
const area = mount();
area.querySelector("#xlsx-formula-btn").click();
assert.equal(area.querySelector("#xlsx-formula-btn").getAttribute("aria-pressed"), "true");
editCell(area, "A1", "=B1*2");
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 0));
assert.equal(calls[0].body.allow_formula, true);
});
// ── A1 — 409 confirmation & force retry ─────────────────────────────────────
await test("409 xlsx_lossy_content asks once then retries with force", async () => {
const area = mount({ lossy: ["slicers"] });
editCell(area, "A1", "250");
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1);
assert.equal(calls.length, 2);
assert.equal(calls[0].body.force, false);
assert.equal(calls[1].body.force, true);
// The prompt names the features the backend reported.
assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]);
// Save succeeded → cells are no longer dirty.
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
});
await test("confirming once is enough for the following saves", async () => {
const area = mount({ lossy: ["cached_values"] });
editCell(area, "A1", "1");
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
editCell(area, "A1", "2");
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1, "the user is not asked twice");
assert.equal(calls.length, 3);
assert.equal(calls[2].body.force, true);
});
await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => {
const area = mount({ lossy: ["slicers"] });
editCell(area, "A1", "250");
nextResponse = () => lossyError;
confirmAnswer = false;
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1);
assert.equal(calls.length, 1, "no retry after a refusal");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
});
await test("a non-409 failure is not retried", async () => {
const area = mount();
editCell(area, "A1", "250");
nextResponse = () => ({ ok: false, status: 500, body: { detail: "boom" } });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 1);
assert.equal(confirmCalls, 0);
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
});
// ── A8 — truncation notice ───────────────────────────────────────────────────
await test("no notice when the sheet fits within the render caps", () => {
const area = mount({
sheet: { rows: 500, cols: 40, total_rows: 500, total_cols: 40, max_rows: 500, max_cols: 40, truncated: false },
});
assert.equal(area.querySelector(".xlsx-truncated"), null);
});
await test("notice states the cap and the real size of a truncated sheet", () => {
const area = mount({
sheet: { rows: 500, cols: 12, total_rows: 1200, total_cols: 12, max_rows: 500, max_cols: 40, truncated: true },
});
const note = area.querySelector(".xlsx-truncated");
assert.ok(note, "notice absent");
const txt = note.textContent;
assert.ok(txt.includes(FR["xlsx.truncated_title"]), txt);
// {shown} is the CAP (500), not the post-trim row count: a sparse sheet
// renders 1 row but the view still reaches 500 of them.
const expected = FR["xlsx.truncated_rows"].replace("{shown}", "500").replace("{total}", "1200");
assert.ok(txt.includes(expected), `${txt} !includes ${expected}`);
// Nothing to say about the columns here (12 < 40).
assert.ok(!txt.includes(FR["xlsx.truncated_cols"]), txt);
});
await test("notice mentions both axes when rows AND columns overflow", () => {
const area = mount({
sheet: { rows: 1, cols: 40, total_rows: 501, total_cols: 45, max_rows: 500, max_cols: 40, truncated: true },
});
const txt = area.querySelector(".xlsx-truncated").textContent;
assert.ok(
txt.includes(FR["xlsx.truncated_cols"].replace("{shown}", "40").replace("{total}", "45")),
txt
);
});
await test("a payload without the dimensions shows no notice", () => {
// Backward compatibility: an older cached response must not produce "NaN".
const area = mount({ sheet: { name: "Feuille1" } });
assert.equal(area.querySelector(".xlsx-truncated"), null);
assert.ok(!area.textContent.includes("NaN"));
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);