feat: garde-fous d'écriture des classeurs Excel #153 (P0)
L'édition d'un .xlsx pouvait détruire une partie du classeur, le concurrencer en silence, ou diffuser une injection de formule. - BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd (valeurs calculées en cache, slicers, contrôles, connexions, custom XML, signature, commentaires enrichis, macros) → xlsx_lossy_features exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans `force` (confirmation explicite puis reprise). Périmètre réel revalidé : graphiques, images et TCD survivent au round-trip. - BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage ne peut plus tronquer le classeur, le backup reste intact. - BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s, 409 conflict) ; endpoint xlsx/save devenu synchrone pour que l'attente s'exécute dans le threadpool. - BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in `allow_formula` ou le bouton f(x) de la visionneuse. Le handler ServiceError expose désormais code + details, que api() propage. - BUG-084 : la suppression d'une vault purge enfin l'index inversé (documents fantômes qui continuaient de matcher) et is_stale() devient is_ready(), le nom étant trompeur (la staleness n'existe plus). Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI), 3 E2E Playwright, suite E2E complète verte, ruff/mypy 0. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
#!/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.
|
||||
*
|
||||
* 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 = [] } = {}) {
|
||||
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") }],
|
||||
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);
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
Reference in New Issue
Block a user