A17 — nouveau endpoint GET /api/file/{vault}/xlsx/dashboard (read_workbook_
dashboard : plages nommees avec portee depuis defined_names read_only,
comptage graphiques/TCD par parts OPC, stats par feuille bornées 500x40 :
cellules/lignes/colonnes/formules/numerique + 8 premieres valeurs en cartes
KPI) et panneau frontend toggled depuis la toolbar (table des plages,
cartes KPI par feuille, hint actions IA). Bouton absent pour .csv et
formats en lecture seule ; le menu structure est saute quand le bouton
n'existe pas. i18n FR/EN (xlsx.dashboard_*), 8 tests backend + 2 tests
JSDOM + contre-preuve (5 echecs sur neutralisation), ruff/mypy 0.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
724 lines
32 KiB
JavaScript
724 lines
32 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.
|
|
* - A9bis : the tail of a truncated sheet is fetched window by window from
|
|
* GET …/xlsx/sheet (scroll sentinel + click), and the appended rows are
|
|
* editable like the initial ones.
|
|
* - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing
|
|
* from the bar marks the cell dirty; Escape reverts.
|
|
* - A13 : header click sorts the rendered rows, the filter hides rows, the
|
|
* find highlights matches, CSV export downloads the visible sheet.
|
|
* - A14 : the structure menu sends one PUT …/xlsx/structure with the action,
|
|
* then re-renders from the server; destructive actions confirm first.
|
|
*
|
|
* 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.DOMParser = w.DOMParser;
|
|
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;
|
|
apiQueue = [];
|
|
nextResponse = () => {
|
|
if (apiQueue.length) return apiQueue.shift();
|
|
return { 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><th>B</th></tr></thead><tbody>' +
|
|
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td><td data-cell="B1">B1</td></tr>` +
|
|
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">A2</td><td data-cell="B2">B2</td></tr>' +
|
|
"</tbody></table></div>";
|
|
|
|
// The JSDOM fetch double serves locale files; everything else is a recorded
|
|
// API call answered by `nextResponse`. `apiQueue` lets a test script the
|
|
// successive windows a lazy-loading sheet will request.
|
|
let apiQueue = [];
|
|
|
|
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;
|
|
}
|
|
|
|
/** Mount a sheet flagged truncated so wireLazyRows() arms the footnote. */
|
|
function mountTruncated({ total = 520, rows = 1 } = {}) {
|
|
return mount({
|
|
sheet: { rows, cols: 3, total_rows: total, total_cols: 3, max_rows: 500, max_cols: 40, truncated: total > 500 },
|
|
});
|
|
}
|
|
|
|
/** 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"));
|
|
});
|
|
|
|
// ── A9bis — lazy loading of the truncated tail ──────────────────────────────
|
|
|
|
const windowHtml = (from, to) =>
|
|
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
|
"<tbody>" +
|
|
Array.from({ length: to - from + 1 }, (_, i) => {
|
|
const r = from + i;
|
|
return `<tr><th class="xlsx-rownum">${r}</th><td data-cell="A${r}">Ligne ${r}</td></tr>`;
|
|
}).join("") +
|
|
"</tbody></table></div>";
|
|
|
|
await test("a truncated sheet gets a load-more footnote, a normal one does not", () => {
|
|
const truncated = mountTruncated();
|
|
assert.ok(truncated.querySelector(".xlsx-load-more"), "footnote absent");
|
|
assert.ok(truncated.querySelector(".xlsx-load-more").textContent.includes(FR["xlsx.load_more"]));
|
|
|
|
const plain = mount({ sheet: { rows: 10, cols: 2, total_rows: 10, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false } });
|
|
assert.equal(plain.querySelector(".xlsx-load-more"), null);
|
|
});
|
|
|
|
await test("clicking the footnote fetches the next window with the right query", async () => {
|
|
const area = mountTruncated();
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 2, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 3) },
|
|
});
|
|
area.querySelector(".xlsx-load-more").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/sheet\?path=data\.xlsx&sheet=Feuille1&offset=1&limit=500/);
|
|
// Rows 2 and 3 landed in the table with their real coordinates.
|
|
assert.ok(area.querySelector('td[data-cell="A2"]'));
|
|
assert.ok(area.querySelector('td[data-cell="A3"]'));
|
|
assert.ok(area.querySelector('th.xlsx-rownum') && area.textContent.includes("Ligne 3"));
|
|
// Everything loaded → the footnote is hidden (kept in the DOM, class `done`).
|
|
const foot = area.querySelector(".xlsx-load-more");
|
|
assert.ok(foot, "the footnote element survives");
|
|
assert.equal(foot.classList.contains("done"), true);
|
|
assert.ok(!foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent);
|
|
});
|
|
|
|
await test("appended rows are editable and tracked as dirty", async () => {
|
|
const area = mountTruncated();
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) },
|
|
});
|
|
area.querySelector(".xlsx-load-more").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
editCell(area, "A2", "modifié");
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.equal(calls.length, 2, "the save went out");
|
|
assert.equal(calls[1].url.includes("/xlsx/save"), true);
|
|
assert.deepEqual(calls[1].body.cells, { A2: "modifié" });
|
|
assert.equal(calls[1].body.sheet, "Feuille1");
|
|
});
|
|
|
|
await test("a failed window fetch keeps the footnote and shows an error toast", async () => {
|
|
const area = mountTruncated();
|
|
apiQueue.push({ ok: false, status: 500, body: { detail: "boom" } });
|
|
area.querySelector(".xlsx-load-more").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
const foot = area.querySelector(".xlsx-load-more");
|
|
assert.ok(foot, "footnote must survive a failed fetch");
|
|
assert.ok(foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent);
|
|
// `api()` itself toasts the failure (shared behaviour, asserted in E2E);
|
|
// here we assert the local consequence: the footnote keeps its label.
|
|
// Retrying works once the server answers again.
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) },
|
|
});
|
|
foot.click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.ok(area.querySelector('td[data-cell="A2"]'));
|
|
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
|
});
|
|
|
|
// ── A7 — formula bar & keyboard navigation ──────────────────────────────────
|
|
|
|
await test("the formula bar starts empty and disabled", () => {
|
|
const area = mount();
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "—");
|
|
assert.equal(area.querySelector("#xlsx-formula-input").disabled, true);
|
|
});
|
|
|
|
await test("focusing a cell shows its name and content in the bar", () => {
|
|
const area = mount();
|
|
const td = area.querySelector('td[data-cell="A1"]');
|
|
td.dispatchEvent(new w.Event("focus", { bubbles: false }));
|
|
// JSDOM does not run the default focus behaviour on dispatchEvent, so go
|
|
// through the real API:
|
|
td.focus();
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
|
|
assert.equal(area.querySelector("#xlsx-formula-input").value, "100");
|
|
assert.equal(area.querySelector("#xlsx-formula-input").disabled, false);
|
|
});
|
|
|
|
await test("typing in the bar edits the cell live and marks it dirty", () => {
|
|
const area = mount();
|
|
const td = area.querySelector('td[data-cell="A1"]');
|
|
td.focus();
|
|
const input = area.querySelector("#xlsx-formula-input");
|
|
input.value = "depuis la barre";
|
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
assert.equal(td.textContent, "depuis la barre");
|
|
assert.equal(td.classList.contains("xlsx-dirty"), true);
|
|
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
|
// The save payload carries the cell edit.
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
return new Promise((r) => setTimeout(r, 5)).then(() => {
|
|
assert.deepEqual(calls[0].body.cells, { A1: "depuis la barre" });
|
|
});
|
|
});
|
|
|
|
await test("Tab and arrows move to the neighbour cell", () => {
|
|
const area = mount();
|
|
const a1 = area.querySelector('td[data-cell="A1"]');
|
|
a1.focus();
|
|
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B1");
|
|
const b1 = area.querySelector('td[data-cell="B1"]');
|
|
b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B2");
|
|
const b2 = area.querySelector('td[data-cell="B2"]');
|
|
b2.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A2");
|
|
});
|
|
|
|
await test("Enter commits and Shift+Tab goes backwards", () => {
|
|
const area = mount();
|
|
const a1 = area.querySelector('td[data-cell="A1"]');
|
|
a1.focus();
|
|
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
|
const b1 = area.querySelector('td[data-cell="B1"]');
|
|
b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
|
|
});
|
|
|
|
await test("a saved edit from the bar resets the dirty flag and orig value", async () => {
|
|
const area = mount();
|
|
const td = area.querySelector('td[data-cell="A1"]');
|
|
td.focus();
|
|
const input = area.querySelector("#xlsx-formula-input");
|
|
input.value = "200";
|
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
|
assert.equal(td.dataset.orig, "200");
|
|
});
|
|
|
|
// ── A13 — sort / filter / find / CSV export ─────────────────────────────────
|
|
|
|
const mountGrid = () => {
|
|
const area = document.getElementById("content-area");
|
|
area.innerHTML = "";
|
|
const grid =
|
|
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
|
'<thead><tr><th class="xlsx-corner"></th><th>A</th><th>B</th></tr></thead><tbody>' +
|
|
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">Banane</td><td data-cell="B1">3</td></tr>' +
|
|
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">Abricot</td><td data-cell="B2">10</td></tr>' +
|
|
'<tr><th class="xlsx-rownum">3</th><td data-cell="A3">Cerise</td><td data-cell="B3">2</td></tr>' +
|
|
"</tbody></table></div>";
|
|
renderXlsxViewer(area, {
|
|
vault: "V", path: "data.xlsx", is_xlsx: true,
|
|
xlsx_sheets: [{ name: "Fruits", html: grid, rows: 3, cols: 2, total_rows: 3, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false }],
|
|
xlsx_lossy_features: [],
|
|
});
|
|
return area;
|
|
};
|
|
|
|
await test("clicking a header sorts the rows numerically or lexically", () => {
|
|
const area = mountGrid();
|
|
// Sort by column B (numbers) ascending: 2, 3, 10.
|
|
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
|
let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
|
assert.deepEqual(cells, ["2", "3", "10"]);
|
|
// Second click: descending.
|
|
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
|
cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
|
assert.deepEqual(cells, ["10", "3", "2"]);
|
|
});
|
|
|
|
await test("a dirty cell travels with its row during a sort", () => {
|
|
const area = mountGrid();
|
|
editCell(area, "A3", "Cerise modifiée");
|
|
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
|
const aCells = [...area.querySelectorAll("tbody td[data-cell^=\"A\"]")].map((td) => td.textContent);
|
|
assert.ok(aCells.includes("Cerise modifiée"), aCells.join("|"));
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
|
});
|
|
|
|
await test("the filter hides the rows that do not match", () => {
|
|
const area = mountGrid();
|
|
// The filter reuses the find input: type and the rows filter live.
|
|
const input = area.querySelector("#xlsx-find-input");
|
|
input.value = "abri";
|
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
const visible = [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display !== "none");
|
|
assert.equal(visible.length, 1);
|
|
assert.ok(visible[0].textContent.includes("Abricot"));
|
|
});
|
|
|
|
await test("find highlights matches and navigates with the counter", () => {
|
|
const area = mountGrid();
|
|
const input = area.querySelector("#xlsx-find-input");
|
|
const count = area.querySelector("#xlsx-find-count");
|
|
input.value = "cerise"; // lowercase: the default search ignores the case
|
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
assert.equal(area.querySelectorAll("mark.xlsx-find-hit").length, 1);
|
|
assert.ok(count.textContent.includes("1/1"), count.textContent);
|
|
// The hit is inside the matching cell.
|
|
assert.ok(area.querySelector('td[data-cell="A3"] mark.xlsx-find-hit'));
|
|
});
|
|
|
|
await test("CSV export downloads the visible sheet without the cached shadows", () => {
|
|
const area = mountGrid();
|
|
const clicks = [];
|
|
const realCreate = document.createElement.bind(document);
|
|
const anchor = realCreate("a");
|
|
document.createElement = (tag) => {
|
|
if (tag === "a") { clicks.push(1); return anchor; }
|
|
return realCreate(tag);
|
|
};
|
|
let href = "";
|
|
Object.defineProperty(anchor, "href", { set(v) { href = v; }, get: () => href });
|
|
URL.createObjectURL = () => "blob:x";
|
|
URL.revokeObjectURL = () => {};
|
|
area.querySelector("#xlsx-csv-btn").click();
|
|
document.createElement = realCreate;
|
|
assert.equal(clicks.length, 1);
|
|
assert.equal(anchor.download, "Fruits.csv");
|
|
});
|
|
|
|
// ── A14 — structure menu ───────────────────────────────────────────────────
|
|
|
|
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
|
|
const area = mount();
|
|
// The prompt is resolved through the module scope: stub it globally.
|
|
const realPrompt = globalThis.prompt;
|
|
globalThis.prompt = () => "Feuille 2";
|
|
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
|
apiQueue.push({
|
|
ok: true, status: 200,
|
|
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
|
}); // re-read
|
|
area.querySelector("#xlsx-structure-btn").click();
|
|
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
|
const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
|
addBtn.click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
globalThis.prompt = realPrompt;
|
|
assert.equal(calls.length, 2);
|
|
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
|
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
|
assert.equal(calls[0].body.force, false);
|
|
// The viewer re-rendered from the server payload (a single sheet → no tabs).
|
|
assert.ok(
|
|
area.querySelector("#content-area, .xlsx-viewer") || area,
|
|
"the viewer was rebuilt",
|
|
);
|
|
assert.ok(
|
|
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
|
|
"the re-render shows the fresh payload",
|
|
);
|
|
});
|
|
|
|
await test("sheet_delete confirms and is refused on the last sheet", async () => {
|
|
const area = mount();
|
|
const delBtn = () => {
|
|
area.querySelector("#xlsx-structure-btn").click();
|
|
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
|
const b = items.find((x) => x.textContent === FR["xlsx.sheet_delete"]);
|
|
b.click();
|
|
};
|
|
// One sheet only → blocked before even confirming (no network call).
|
|
delBtn();
|
|
assert.equal(calls.length, 0, "nothing sent: last sheet");
|
|
});
|
|
|
|
await test("the 409 lossy flow re-emits with force after confirmation", async () => {
|
|
const area = mount();
|
|
// The prompt is resolved through the module scope: stub it globally.
|
|
const realPrompt = globalThis.prompt;
|
|
globalThis.prompt = () => "Feuille 2";
|
|
apiQueue.push({
|
|
ok: false, status: 409,
|
|
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
|
|
});
|
|
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // retry w/ force
|
|
apiQueue.push({
|
|
ok: true, status: 200,
|
|
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
|
|
});
|
|
area.querySelector("#xlsx-structure-btn").click();
|
|
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
|
items.find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
globalThis.prompt = realPrompt;
|
|
assert.equal(confirmCalls, 1, "the user is asked about the lossy write");
|
|
assert.equal(calls.length, 3);
|
|
assert.equal(calls[1].body.force, true);
|
|
});
|
|
|
|
// ── A17 — dashboard panel ─────────────────────────────────────────────
|
|
await test("the dashboard button fetches the metadata and renders named ranges + KPIs", async () => {
|
|
const area = mount();
|
|
const dashBtn = area.querySelector("#xlsx-dashboard-btn");
|
|
assert.ok(dashBtn, "the dashboard button exists for an editable workbook");
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
vault: "V",
|
|
path: "data.xlsx",
|
|
named_ranges: [{ name: "MaPlage", scope: "", ref: "Data!$A$1:$B$5" }],
|
|
objects: { charts: 2, pivots: 1 },
|
|
sheets: [{ name: "Feuille1", cells: 9, rows: 3, cols: 3, formulas: 1, numeric: 2, kpi: [{ label: "A2", value: 12 }] }],
|
|
},
|
|
});
|
|
dashBtn.click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.match(calls[0].url, /\/xlsx\/dashboard\?path=data\.xlsx/);
|
|
const panel = area.querySelector(".xlsx-dashboard");
|
|
assert.ok(panel, "the dashboard panel is rendered");
|
|
assert.ok(panel.textContent.includes("MaPlage"), "named range is listed");
|
|
assert.ok(panel.textContent.includes("A2"), "KPI label is rendered");
|
|
assert.ok(panel.textContent.includes("12"), "KPI value is rendered");
|
|
// Second click closes the panel.
|
|
dashBtn.click();
|
|
assert.equal(area.querySelector(".xlsx-dashboard"), null, "panel toggles closed");
|
|
});
|
|
|
|
await test("a csv mounts without the dashboard button", () => {
|
|
const area = document.getElementById("content-area");
|
|
area.innerHTML = "";
|
|
renderXlsxViewer(area, {
|
|
vault: "V",
|
|
path: "data.csv",
|
|
is_csv: true,
|
|
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
|
xlsx_lossy_features: [],
|
|
});
|
|
assert.equal(area.querySelector("#xlsx-dashboard-btn"), null, "no dashboard for csv");
|
|
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure menu for csv");
|
|
assert.ok(area.querySelector("td[data-cell=\"A1\"]"), "the grid is still editable");
|
|
});
|
|
|
|
await test("two windows in a row walk the whole sheet", async () => {
|
|
const area = mountTruncated({ total: 1200 });
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 500, total_rows: 1200, truncated: true, has_more: true, html: windowHtml(2, 501) },
|
|
});
|
|
area.querySelector(".xlsx-load-more").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.ok(area.querySelector('td[data-cell="A501"]'));
|
|
// The footnote still shows, with the updated progress.
|
|
let foot = area.querySelector(".xlsx-load-more");
|
|
assert.ok(foot, "more rows remain");
|
|
assert.ok(foot.textContent.includes("501"), foot.textContent);
|
|
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: { sheet: "Feuille1", offset: 501, limit: 500, rows: 200, total_rows: 1200, truncated: true, has_more: false, html: windowHtml(502, 701) },
|
|
});
|
|
foot.click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.ok(area.querySelector('td[data-cell="A701"]'));
|
|
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
|
});
|
|
|
|
// ── Report ──────────────────────────────────────────────────────────────────
|
|
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
|
process.exit(passCount === testCount ? 0 : 1);
|