#!/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( `
`, { 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" } }; }; // #154-A2 — themed dialogs live in document.body; clear any left behind by a // previous test so the helpers always reach the current one. document.querySelectorAll(".obsigate-modal-overlay").forEach((n) => n.remove()); // #156-A10 — the undo/redo stacks are keyed by file and every helper mounts // the same vault/path: reset them so tests stay independent. resetXlsxHistory(); 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, resetXlsxHistory } = 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) => '
' + '' + `` + '' + "
AB
1${value}B1
2A2B2
"; // 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 = {}, revision = null } = {}) { 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, // #156-A12 — optimistic-concurrency token returned by the read. ...(revision ? { xlsx_revision: revision } : {}), }); 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 }, }); } /** * Mount a CSV exactly as the backend serves it: a single `html` table and NO * `xlsx_sheets` (the shape that used to crash the save path, BUG-096). */ function mountCsv(html = sheetHtml("100")) { const area = document.getElementById("content-area"); area.innerHTML = ""; renderXlsxViewer(area, { vault: "V", path: "budget.csv", is_csv: true, is_markdown: false, html, }); 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; } /** #154-A2 — the themed dialog is the last overlay in document.body. */ const lastDialog = () => { const overlays = document.querySelectorAll(".obsigate-modal-overlay"); return overlays[overlays.length - 1] || null; }; const clickDialog = (which) => { const btn = lastDialog()?.querySelector(`[data-dialog="${which}"]`); assert.ok(btn, `dialog button "${which}" is present`); btn.click(); }; /** #155 — open the grid context menu on a cell (right-click). */ const openMenuOn = (area, ref) => { const td = area.querySelector(`td[data-cell="${ref}"]`); assert.ok(td, `cell ${ref} exists`); td.dispatchEvent(new w.MouseEvent("contextmenu", { bubbles: true, clientX: 12, clientY: 12 })); return document.querySelector(".xlsx-context-menu"); }; const menuItem = (label) => [...document.querySelectorAll(".xlsx-context-item")].find((b) => b.textContent === label); 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)); // #154-A2 — a themed dialog replaces window.confirm(), and no retry happens // before the user answers. const dialog = lastDialog(); assert.ok(dialog, "a themed confirmation is shown"); assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent); assert.equal(calls.length, 1, "nothing is retried before the answer"); clickDialog("confirm"); await new Promise((r) => setTimeout(r, 10)); assert.equal(calls.length, 2); assert.equal(calls[0].body.force, false); assert.equal(calls[1].body.force, true); // 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)); clickDialog("confirm"); 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(calls.length, 3); assert.equal(calls[2].body.force, true); // Wait out the dialog close animation, then confirm none is left. await new Promise((r) => setTimeout(r, 250)); assert.equal(document.querySelectorAll(".obsigate-modal-overlay").length, 0, "the user is not asked twice"); }); await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => { const area = mount({ lossy: ["slicers"] }); editCell(area, "A1", "250"); nextResponse = () => lossyError; area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); clickDialog("cancel"); await new Promise((r) => setTimeout(r, 10)); 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 409 conflict shows a non-blocking retry banner and keeps the edits", async () => { const area = mount(); editCell(area, "A1", "250"); nextResponse = () => ({ ok: false, status: 409, body: { detail: "busy", code: "conflict" } }); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); const banner = area.querySelector(".xlsx-banner-conflict"); assert.ok(banner, "a conflict banner is shown"); assert.ok(banner.textContent.includes(FR["xlsx.conflict_msg"]), banner.textContent); // The edit is preserved and the save button is usable again. assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1); assert.equal(area.querySelector("#xlsx-save-btn").disabled, false); // The retry re-runs the save. nextResponse = () => ({ ok: true, status: 200, body: {} }); banner.querySelector(".xlsx-banner-retry").click(); await new Promise((r) => setTimeout(r, 10)); // #156-A12 — the retry re-reads the file first (to pick up the current // revision), then re-sends the save: one extra request. assert.equal(calls.length, 3); assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry"); }); 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) => '
' + "" + Array.from({ length: to - from + 1 }, (_, i) => { const r = from + i; return ``; }).join("") + "
${r}Ligne ${r}
"; 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").value, ""); 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").value, "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").value, "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").value, "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").value, "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").value, "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 = (revision = null) => { const area = document.getElementById("content-area"); area.innerHTML = ""; const grid = '
' + '' + '' + '' + '' + "
AB
1Banane3
2Abricot10
3Cerise2
"; 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: [], ...(revision ? { xlsx_revision: revision } : {}), }); return area; }; await test("the context menu sorts the rows numerically or lexically", () => { const area = mountGrid(); // Sort by column B (numbers) ascending: 2, 3, 10. openMenuOn(area, "B1"); menuItem(FR["xlsx.sort_asc"]).click(); let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent); assert.deepEqual(cells, ["2", "3", "10"]); // Re-open and sort descending. openMenuOn(area, "B1"); menuItem(FR["xlsx.sort_desc"]).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"); openMenuOn(area, "B1"); menuItem(FR["xlsx.sort_asc"]).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(); 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 addBtn = [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]); addBtn.click(); await new Promise((r) => setTimeout(r, 5)); // #154-A2 — a themed prompt replaces window.prompt(). const input = lastDialog()?.querySelector('[data-dialog="input"]'); assert.ok(input, "a themed prompt asks for the sheet name"); input.value = "Feuille 2"; clickDialog("confirm"); await new Promise((r) => setTimeout(r, 10)); 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); 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 answered through the themed dialog. 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(); [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]).click(); await new Promise((r) => setTimeout(r, 5)); lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2"; clickDialog("confirm"); await new Promise((r) => setTimeout(r, 10)); const lossyDialog = lastDialog(); assert.ok(lossyDialog && lossyDialog.textContent.includes(FR["xlsx.feature_slicers"]), "a lossy confirmation is shown"); clickDialog("confirm"); await new Promise((r) => setTimeout(r, 10)); assert.equal(calls.length, 3); assert.equal(calls[1].body.force, true); }); // ── BUG-094 — structure actions work on an empty/new sheet ───────────────── await test("the structure menu offers row/column actions without an active cell", () => { const area = mount(); area.querySelector("#xlsx-structure-btn").click(); const labels = [...area.querySelectorAll(".xlsx-structure-item")].map((b) => b.textContent); assert.ok(labels.includes(FR["xlsx.row_insert"]), "row insert available before any click"); assert.ok(labels.includes(FR["xlsx.col_insert"]), "column insert available before any click"); }); // ── 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("the dashboard opens in the right-hand inspector, not inline in the grid", async () => { const area = mount(); apiQueue.push({ ok: true, status: 200, body: { vault: "V", path: "data.xlsx", named_ranges: [], objects: { charts: 0, pivots: 0 }, sheets: [{ name: "Feuille1", cells: 1, rows: 1, cols: 1, formulas: 0, numeric: 0, kpi: [] }] }, }); area.querySelector("#xlsx-dashboard-btn").click(); await new Promise((r) => setTimeout(r, 5)); const inspector = area.querySelector("#xlsx-inspector"); assert.equal(inspector.classList.contains("open"), true, "the inspector opens"); assert.equal(inspector.getAttribute("aria-hidden"), "false"); assert.ok(inspector.querySelector("#xlsx-inspector-body .xlsx-dashboard"), "the dashboard lives in the inspector"); // The grid is still present beside it (not replaced). assert.ok(area.querySelector(".xlsx-main .xlsx-table"), "the grid stays visible"); // Closing removes the panel and closes the inspector. area.querySelector("#xlsx-inspector-close").click(); assert.equal(area.querySelector(".xlsx-dashboard"), null); assert.equal(inspector.classList.contains("open"), false); assert.equal(inspector.getAttribute("aria-hidden"), "true"); }); await test("the inspector exposes an AI assistant entry point", () => { const area = mount(); assert.ok(area.querySelector("#xlsx-inspector-ai"), "AI button in the inspector head"); const title = area.querySelector("#xlsx-inspector .xlsx-inspector-title"); assert.equal(title.textContent, FR["xlsx.inspector_title"]); }); 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); }); // ── #154-A1 — command bar, sheet tabs, status pills ───────────────────────── await test("a single-sheet workbook still shows its tab and a + button", () => { const area = mount(); assert.ok(area.querySelector(".xlsx-tabs"), "the tab strip is always rendered"); assert.equal(area.querySelectorAll(".xlsx-tab").length, 1); const add = area.querySelector("#xlsx-tab-add"); assert.ok(add, "the + button is present for an editable sheet"); assert.equal(add.getAttribute("title"), FR["xlsx.tabs_add_sheet"]); }); await test("a csv shows no tab strip and no + 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-tabs"), null); assert.equal(area.querySelector("#xlsx-tab-add"), null); }); await test("saving an edited csv PUTs /csv/save (payload without xlsx_sheets)", async () => { // BUG-096 — a CSV read payload has no `xlsx_sheets`; building the save job // from `sheets[0].name` used to throw a TypeError before any request, so a // CSV edit could never be saved from the viewer. const area = mountCsv(); editCell(area, "A1", "modifié"); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); assert.equal(calls.length, 1, "exactly one save request"); assert.match(calls[0].url, /\/api\/file\/V\/csv\/save\?path=budget\.csv/); assert.deepEqual(calls[0].body.cells, { A1: "modifié" }); // Committed: the cell is no longer dirty and the button is disabled again. assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); assert.equal(area.querySelector("#xlsx-save-btn").disabled, true); }); await test("the + button adds a sheet through the structure endpoint", async () => { const area = mount(); 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-tab-add").click(); await new Promise((r) => setTimeout(r, 5)); lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2"; clickDialog("confirm"); await new Promise((r) => setTimeout(r, 10)); 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(area.querySelector('td[data-cell="A1"]')?.textContent, "neuf"); }); await test("a read-only workbook shows the read-only pill and offers no editing", () => { const area = document.getElementById("content-area"); area.innerHTML = ""; renderXlsxViewer(area, { vault: "V", path: "data.xls", is_xlsx: true, xlsx_readonly: true, xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [], }); const pill = area.querySelector(".xlsx-status-readonly"); assert.ok(pill, "read-only pill"); assert.ok(pill.textContent.includes(FR["xlsx.readonly_badge"]), pill.textContent); assert.equal(area.querySelector("#xlsx-tab-add"), null, "no + for read-only"); assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure for read-only"); assert.equal(area.querySelector("#xlsx-undo-btn"), null, "no history for read-only"); assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("contenteditable"), null, "cells are not editable"); }); await test("the formulas-not-recalculated pill is shown for xlsx but not csv", () => { const area = mount(); const pill = area.querySelector(".xlsx-status-formula"); assert.ok(pill, "formula pill on xlsx"); assert.ok(pill.textContent.includes(FR["xlsx.formulas_note"]), pill.textContent); const csvArea = document.getElementById("content-area"); csvArea.innerHTML = ""; renderXlsxViewer(csvArea, { vault: "V", path: "data.csv", is_csv: true, xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [], }); assert.equal(csvArea.querySelector(".xlsx-status-formula"), null); }); await test("the command bar exposes grouped actions with a primary save", () => { const area = mount(); assert.ok(area.querySelector(".xlsx-cmdbar"), "grouped command bar"); assert.ok(area.querySelector('#xlsx-save-btn').classList.contains("xlsx-save-primary")); assert.ok(area.querySelector(".xlsx-cmd-group[data-group='formulas']")); assert.ok(area.querySelector(".xlsx-cmd-group[data-group='insert']")); assert.ok(area.querySelector(".xlsx-cmd-group[data-group='file']")); }); // ── #154-A4 — undo/redo, ARIA ─────────────────────────────────────────────── await test("undo/redo restores cell edits from the command stack", () => { const area = mount(); const td = area.querySelector('td[data-cell="A1"]'); const undoBtn = area.querySelector("#xlsx-undo-btn"); const redoBtn = area.querySelector("#xlsx-redo-btn"); assert.equal(undoBtn.disabled, true, "nothing to undo yet"); td.focus(); editCell(area, "A1", "42"); td.dispatchEvent(new w.Event("blur")); assert.equal(undoBtn.disabled, false, "undo becomes available after a committed edit"); undoBtn.click(); assert.equal(td.textContent, "100", "undo restores the original value"); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0, "the cell is clean again"); assert.equal(redoBtn.disabled, false, "redo becomes available"); redoBtn.click(); assert.equal(td.textContent, "42", "redo reapplies the edit"); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1, "the edit is dirty again"); }); await test("a no-op undo/redo pair leaves the buttons disabled", () => { const area = mount(); assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true); assert.equal(area.querySelector("#xlsx-redo-btn").disabled, true); }); await test("the grid exposes ARIA grid semantics", () => { const area = mount(); const table = area.querySelector(".xlsx-table"); assert.equal(table.getAttribute("role"), "grid"); assert.equal(area.querySelector(".xlsx-table thead th").getAttribute("role"), "columnheader"); assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("role"), "gridcell"); assert.equal(area.querySelector("th.xlsx-rownum").getAttribute("role"), "rowheader"); }); // ── #154-A5 — extracted refs module & dashboard → grid link ──────────────── const { parseRef, columnName, firstCellOfRange, sheetOfRef } = await import( pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "xlsx", "refs.js")).href ); await test("parseRef / columnName round-trip A1 references", () => { assert.deepEqual(parseRef("A1"), { row: 1, col: 1 }); assert.deepEqual(parseRef("B12"), { row: 12, col: 2 }); assert.deepEqual(parseRef("AA3"), { row: 3, col: 27 }); assert.equal(parseRef("A"), null); assert.equal(columnName(1), "A"); assert.equal(columnName(26), "Z"); assert.equal(columnName(27), "AA"); assert.equal(columnName(parseRef("AB9").col), "AB"); }); await test("range helpers extract the sheet and the first cell", () => { assert.equal(firstCellOfRange("Data!$A$1:$B$5"), "A1"); assert.equal(firstCellOfRange("'Mon onglet'!$C$3"), "C3"); assert.equal(firstCellOfRange("A1:B2"), "A1"); assert.equal(sheetOfRef("Data!$A$1:$B$5"), "Data"); assert.equal(sheetOfRef("'Mon onglet'!$C$3"), "Mon onglet"); assert.equal(sheetOfRef("A1:B2"), null); }); await test("clicking a named range reveals its first cell in the grid", async () => { const area = mount(); apiQueue.push({ ok: true, status: 200, body: { vault: "V", path: "data.xlsx", named_ranges: [{ name: "MaPlage", scope: "", ref: "Feuille1!$A$1:$B$2" }], objects: { charts: 0, pivots: 0 }, sheets: [] }, }); area.querySelector("#xlsx-dashboard-btn").click(); await new Promise((r) => setTimeout(r, 5)); const row = area.querySelector(".xlsx-range-row[data-ref]"); assert.ok(row, "the named range row is rendered"); row.click(); assert.equal(area.querySelector("#xlsx-active-cell").value, "A1", "the anchor cell becomes active"); assert.ok(area.querySelector('td[data-cell="A1"]').classList.contains("xlsx-active")); }); await test("the inspector exposes a resize handle", () => { const area = mount(); assert.ok(area.querySelector("#xlsx-inspector-resize"), "resize handle present"); }); // ── #155 — selection & context menu ───────────────────────────────────────── await test("a right-click on a cell opens a themed context menu", () => { const area = mount(); const menu = openMenuOn(area, "A1"); assert.ok(menu, "menu rendered"); assert.ok(menuItem(FR["xlsx.insert_row_above"]), "insert row item"); assert.ok(menuItem(FR["xlsx.sort_asc"]), "sort item"); assert.ok(menuItem(FR["xlsx.clear_contents"]), "clear item"); }); await test("the context menu inserts a row through the structure endpoint", async () => { const area = mount(); 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: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [] }, }); // re-read openMenuOn(area, "A2"); menuItem(FR["xlsx.insert_row_above"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.equal(calls.length, 2); assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/); assert.deepEqual(calls[0].body.actions, [{ op: "row_insert", sheet: "Feuille1", at: 2, count: 1 }]); }); await test("clear contents empties the selected cells (local edit)", () => { const area = mount(); openMenuOn(area, "A1"); menuItem(FR["xlsx.clear_contents"]).click(); assert.equal(area.querySelector('td[data-cell="A1"]').textContent, ""); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1); assert.equal(area.querySelector("#xlsx-save-btn").disabled, false); }); await test("clicking a column header selects the whole column", () => { const area = mount(); area.querySelector(".xlsx-table thead th:nth-child(2)").click(); // column A assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:A2"); const selected = [...area.querySelectorAll("td.xlsx-selected")].map((td) => td.dataset.cell).sort(); assert.deepEqual(selected, ["A1", "A2"]); }); await test("clicking a row header selects the whole row", () => { const area = mount(); area.querySelector("th.xlsx-rownum").click(); // row 1 assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B1"); }); await test("dragging from a cell to another selects the range", () => { const area = mount(); const viewer = area.querySelector(".xlsx-viewer"); const a1 = area.querySelector('td[data-cell="A1"]'); const b2 = area.querySelector('td[data-cell="B2"]'); a1.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 })); b2.dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true, button: 0 })); viewer.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true })); assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B2"); assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4); }); await test("Shift+Arrow extends the selection from the anchor", () => { const area = mount(); const a1 = area.querySelector('td[data-cell="A1"]'); a1.focus(); a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true, bubbles: true })); assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B1"); assert.equal(area.querySelectorAll("td.xlsx-selected").length, 2); }); await test("the context menu module exposes open/close helpers", async () => { const mod = await import( pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")).href ); const menu = mod.openContextMenu(5, 5, [{ label: "Only" }]); assert.equal(menu.querySelectorAll(".xlsx-context-item").length, 1); assert.equal(mod.isContextMenuOpen(), true); mod.closeContextMenu(); assert.equal(mod.isContextMenuOpen(), false); }); // ── #156 (A5/A6/A7) — presse-papiers, clavier complet, zone Nom ──────────── /** #156-A5 — a 6×6 grid (A1:F6) so a pasted block has room to land. */ const mountWide = () => { const area = document.getElementById("content-area"); area.innerHTML = ""; const cols = ["A", "B", "C", "D", "E", "F"]; const head = '' + cols.map((c) => `${c}`).join("") + ""; let body = ""; for (let r = 1; r <= 6; r++) { body += `${r}` + cols.map((c) => `${c}${r}`).join("") + ""; } body += ""; const html = `
${head}${body}
`; renderXlsxViewer(area, { vault: "V", path: "data.xlsx", is_xlsx: true, xlsx_sheets: [{ name: "Grille", html, rows: 6, cols: 6, total_rows: 6, total_cols: 6, max_rows: 500, max_cols: 40, truncated: false }], xlsx_lossy_features: [], }); return area; }; /** * Dispatch a copy/cut/paste event carrying a plain-text payload (JSDOM has no * real clipboard, so `clipboardData` is a minimal stub). */ const clipboardEvent = (type, area, ref, text) => { // A leftover document selection would make the handler think the user is // editing a cell: start from a clean selection. const sel = w.getSelection(); if (sel && sel.removeAllRanges) sel.removeAllRanges(); const td = area.querySelector(`td[data-cell="${ref}"]`); assert.ok(td, `cell ${ref} exists`); const store = { text }; const ev = new w.Event(type, { bubbles: true, cancelable: true }); Object.defineProperty(ev, "clipboardData", { value: { getData: (kind) => (kind === "text/plain" || kind === "text" ? store.text : ""), setData: (kind, value) => { if (kind === "text/plain" || kind === "text") store.text = value; }, }, }); td.dispatchEvent(ev); return { td, store, ev }; }; /** Select A1:B2 by dragging (the gesture the grid listens to). */ const dragSelect = (area, from, to) => { area.querySelector(`td[data-cell="${from}"]`).dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 })); area.querySelector(`td[data-cell="${to}"]`).dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true, button: 0 })); area.querySelector(".xlsx-viewer").dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true })); }; const selectedRefs = (area) => [...area.querySelectorAll("td.xlsx-selected")].map((td) => td.dataset.cell).sort(); const cellTexts = (area, refs) => refs.map((r) => area.querySelector(`td[data-cell="${r}"]`).textContent); const toastCount = () => document.querySelectorAll("#toast-container .toast").length; const pressKey = (target, key, mods = {}) => target.dispatchEvent(new w.KeyboardEvent("keydown", { key, bubbles: true, ...mods })); // ── A5 — presse-papiers de plage ──────────────────────────────────────────── await test("copying a range exports TSV and pasting it fills the target block", () => { const area = mountWide(); dragSelect(area, "A1", "B2"); const copied = clipboardEvent("copy", area, "A1", ""); assert.equal(copied.ev.defaultPrevented, true, "the range copy is handled by the grid"); assert.equal(copied.store.text, "A1\tB1\nA2\tB2", "TSV block on the clipboard"); const pasted = clipboardEvent("paste", area, "D5", copied.store.text); assert.equal(pasted.ev.defaultPrevented, true); assert.deepEqual(cellTexts(area, ["D5", "E5", "D6", "E6"]), ["A1", "B1", "A2", "B2"]); assert.equal(area.querySelector('td[data-cell="F6"]').textContent, "F6", "the rest of the grid is untouched"); assert.equal(area.querySelector("#xlsx-active-cell").value, "D5:E6", "the pasted block stays selected"); assert.deepEqual(selectedRefs(area), ["D5", "D6", "E5", "E6"]); assert.equal(area.querySelector("#xlsx-save-btn").disabled, false, "the paste is unsaved work"); }); await test("a block copied in Excel pastes as a range (trailing newline ignored)", () => { const area = mountWide(); clipboardEvent("paste", area, "A3", "1\t2\n3\t4\n"); assert.deepEqual(cellTexts(area, ["A3", "B3", "A4", "B4"]), ["1", "2", "3", "4"]); assert.equal(area.querySelector('td[data-cell="A5"]').textContent, "A5", "no phantom fifth row"); }); await test("a single-cell paste replaces the cell content", () => { const area = mountWide(); const td = area.querySelector('td[data-cell="C4"]'); td.focus(); clipboardEvent("paste", area, "C4", "remplacé"); assert.equal(td.textContent, "remplacé"); assert.equal(td.classList.contains("xlsx-dirty"), true); assert.equal(area.querySelector("#xlsx-formula-input").value, "remplacé", "the bar follows"); }); await test("cutting a range clears the source once it is pasted elsewhere", () => { const area = mountWide(); area.querySelector("th.xlsx-rownum").click(); // row 1 of a 6-column grid const cut = clipboardEvent("cut", area, "A1", ""); assert.equal(cut.store.text, "A1\tB1\tC1\tD1\tE1\tF1"); clipboardEvent("paste", area, "A5", cut.store.text); assert.deepEqual(cellTexts(area, ["A5", "B5"]), ["A1", "B1"]); assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "", "the cut source is cleared"); assert.equal(area.querySelector('td[data-cell="B1"]').textContent, ""); }); await test("cut then paste in place keeps the pasted values", () => { const area = mountWide(); area.querySelector("th.xlsx-rownum").click(); // A1:F1 const cut = clipboardEvent("cut", area, "A1", ""); clipboardEvent("paste", area, "A1", cut.store.text); assert.deepEqual(cellTexts(area, ["A1", "B1"]), ["A1", "B1"], "no self-erase"); }); await test("the context menu copies and pastes a block", () => { const area = mountWide(); openMenuOn(area, "A1"); assert.ok(menuItem(FR["xlsx.copy"]), "Copier"); assert.ok(menuItem(FR["xlsx.cut"]), "Couper"); assert.ok(menuItem(FR["xlsx.paste"]), "Coller"); area.querySelector("th.xlsx-rownum").click(); openMenuOn(area, "A1"); menuItem(FR["xlsx.copy"]).click(); openMenuOn(area, "A4"); menuItem(FR["xlsx.paste"]).click(); assert.equal(area.querySelector('td[data-cell="A4"]').textContent, "A1"); assert.equal(area.querySelector('td[data-cell="B4"]').textContent, "B1"); }); await test("pasting with a stale clipboard is a no-op with a hint", async () => { const area = mountWide(); const before = toastCount(); openMenuOn(area, "A1"); menuItem(FR["xlsx.paste"]).click(); await new Promise((r) => setTimeout(r, 5)); assert.equal(toastCount(), before + 1, "the user is told the clipboard is unusable"); assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "A1", "nothing was written"); }); await test("a block that overflows the rendered grid is trimmed and signalled", () => { const area = mountWide(); const before = toastCount(); clipboardEvent("paste", area, "E6", "1\t2\t3\n4\t5\t6"); assert.deepEqual(cellTexts(area, ["E6", "F6"]), ["1", "2"]); assert.equal(area.querySelector('td[data-cell="F7"]'), null); assert.equal(toastCount(), before + 1, "the overflow is announced"); }); await test("a pasted block is undoable cell by cell", () => { const area = mountWide(); clipboardEvent("paste", area, "D5", "x\ty"); area.querySelector("#xlsx-undo-btn").click(); assert.equal(area.querySelector('td[data-cell="E5"]').textContent, "E5", "the last pasted cell is back"); area.querySelector("#xlsx-undo-btn").click(); assert.equal(area.querySelector('td[data-cell="D5"]').textContent, "D5"); }); await test("the cached-value shadow never leaks into a copied block", () => { const area = document.getElementById("content-area"); area.innerHTML = ""; const html = '
' + '' + '' + "
A
1=SOMME(B1:B2)' + '42
"; renderXlsxViewer(area, { vault: "V", path: "data.xlsx", is_xlsx: true, xlsx_sheets: [{ name: "F", html }], xlsx_lossy_features: [], }); area.querySelector('td[data-cell="A1"]').focus(); const copied = clipboardEvent("copy", area, "A1", ""); assert.equal(copied.store.text, "=SOMME(B1:B2)", "the formula, not Excel's shadow value"); }); // ── A6 — clavier complet ──────────────────────────────────────────────────── await test("Ctrl+S saves the pending edits", async () => { const area = mountGrid(); editCell(area, "A1", "Ctrl+S"); pressKey(area.querySelector(".xlsx-viewer"), "s", { ctrlKey: true }); await new Promise((r) => setTimeout(r, 5)); assert.equal(calls.length, 1, "one save request"); assert.match(calls[0].url, /\/xlsx\/save/); assert.deepEqual(calls[0].body.cells, { A1: "Ctrl+S" }); }); await test("Suppr clears the selected range and stays undoable", () => { const area = mountGrid(); area.querySelector("th.xlsx-rownum").click(); // A1:B1 pressKey(area.querySelector(".xlsx-viewer"), "Delete"); assert.deepEqual(cellTexts(area, ["A1", "B1"]), ["", ""]); assert.equal(area.querySelector('td[data-cell="A2"]').textContent, "Abricot", "only the selection"); area.querySelector("#xlsx-undo-btn").click(); assert.equal(area.querySelector('td[data-cell="B1"]').textContent, "3"); area.querySelector("#xlsx-undo-btn").click(); assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "Banane"); }); await test("Suppr while typing in a cell deletes text instead of the selection", () => { const area = mountGrid(); const td = area.querySelector('td[data-cell="A1"]'); td.focus(); pressKey(td, "x"); // typing switches the cell to edit mode assert.equal(td.dataset.editing, "1", "typing enters edit mode"); pressKey(td, "Delete"); assert.equal(td.textContent, "Banane", "the caret key was not hijacked"); }); await test("F2 enters the cell for text editing", () => { const area = mountGrid(); const td = area.querySelector('td[data-cell="A1"]'); td.focus(); assert.equal(td.dataset.editing, "", "navigation mode after a plain focus"); pressKey(td, "F2"); assert.equal(td.dataset.editing, "1"); }); await test("Maj+Entrée inserts a line break inside the cell", () => { const area = mountGrid(); const td = area.querySelector('td[data-cell="A1"]'); td.focus(); pressKey(td, "Enter", { shiftKey: true }); assert.ok(td.textContent.includes("\n"), `expected a newline in ${JSON.stringify(td.textContent)}`); assert.equal(td.classList.contains("xlsx-dirty"), true); }); await test("Ctrl+A selects the rendered sheet, Ctrl+Home/End walk its corners", () => { const area = mountGrid(); const viewer = area.querySelector(".xlsx-viewer"); area.querySelector('td[data-cell="B2"]').focus(); pressKey(viewer, "a", { ctrlKey: true }); assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B3"); assert.equal(area.querySelectorAll("td.xlsx-selected").length, 6); pressKey(viewer, "Home", { ctrlKey: true }); assert.equal(area.querySelector("#xlsx-active-cell").value, "A1"); pressKey(viewer, "End", { ctrlKey: true }); assert.equal(area.querySelector("#xlsx-active-cell").value, "B3"); }); await test("Home/End reach the row edges, Ctrl+flèches the data run", () => { const area = mountWide(); const viewer = area.querySelector(".xlsx-viewer"); area.querySelector('td[data-cell="C3"]').focus(); pressKey(viewer, "End"); assert.equal(area.querySelector("#xlsx-active-cell").value, "F3"); pressKey(viewer, "Home"); assert.equal(area.querySelector("#xlsx-active-cell").value, "A3"); pressKey(viewer, "ArrowDown", { ctrlKey: true }); assert.equal(area.querySelector("#xlsx-active-cell").value, "A6", "end of the filled column"); }); await test("PageDown/PageUp move by a screenful", () => { const area = mountWide(); const viewer = area.querySelector(".xlsx-viewer"); area.querySelector('td[data-cell="A1"]').focus(); pressKey(viewer, "PageDown"); assert.equal(area.querySelector("#xlsx-active-cell").value, "A6", "clamped to the last rendered row"); pressKey(viewer, "PageUp"); assert.equal(area.querySelector("#xlsx-active-cell").value, "A1"); }); // ── A7 — zone Nom éditable & aide à la saisie ────────────────────────────── await test("typing a reference in the Name Box activates that cell", () => { const area = mountGrid(); const box = area.querySelector("#xlsx-active-cell"); box.value = "B3"; pressKey(box, "Enter"); assert.equal(box.value, "B3"); assert.equal(area.querySelector('td[data-cell="B3"]').classList.contains("xlsx-active"), true); assert.equal(area.querySelector("#xlsx-formula-input").value, "2"); }); await test("the Name Box accepts a range and normalises it", () => { const area = mountGrid(); const box = area.querySelector("#xlsx-active-cell"); box.value = "$a1:$b2"; pressKey(box, "Enter"); assert.equal(box.value, "A1:B2"); assert.deepEqual(selectedRefs(area), ["A1", "A2", "B1", "B2"]); }); await test("an unknown reference is refused and the previous address restored", () => { const area = mountGrid(); area.querySelector('td[data-cell="A1"]').focus(); const box = area.querySelector("#xlsx-active-cell"); const before = toastCount(); box.value = "XYZ"; pressKey(box, "Enter"); assert.equal(box.value, "A1"); assert.equal(toastCount(), before + 1, "the user is warned"); }); await test("a reference naming another sheet switches tab", () => { const area = document.getElementById("content-area"); area.innerHTML = ""; const other = '
' + '' + '
A
1Autre
'; renderXlsxViewer(area, { vault: "V", path: "data.xlsx", is_xlsx: true, xlsx_sheets: [ { name: "Feuille1", html: sheetHtml("100") }, { name: "Autre", html: other }, ], xlsx_lossy_features: [], }); const box = area.querySelector("#xlsx-active-cell"); box.value = "Autre!A1"; pressKey(box, "Enter"); assert.equal(box.value, "A1"); assert.equal(area.querySelector('.xlsx-tab[data-sheet="1"]').classList.contains("active"), true); assert.equal(area.querySelector('.xlsx-panel[data-sheet="1"]').style.display, "", "the target sheet is shown"); }); await test("the formula bar offers function-name suggestions", () => { const area = mountGrid(); const list = area.querySelector("#xlsx-function-list"); assert.ok(list, "the datalist exists"); const names = [...list.querySelectorAll("option")].map((o) => o.value); assert.ok(names.length >= 10, `expected a list of functions, got ${names.length}`); assert.ok(names.includes("SOMME"), names.join(",")); assert.equal(area.querySelector("#xlsx-formula-input").getAttribute("list"), "xlsx-function-list"); }); // ── #156-A9 — calcul : pas de moteur, l'écart est annoncé ─────────────────── await test("saving a value starting with '=' warns it is stored as text (#156-A9)", async () => { const area = mountGrid(); const before = toastCount(); editCell(area, "A1", "=B1*2"); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 5)); assert.equal(calls.length, 1, "the save still goes out"); assert.equal(calls[0].body.allow_formula, false); const added = [...document.querySelectorAll("#toast-container .toast")].slice(before); assert.ok(added.some((n) => n.textContent.includes("f(x")), "the f(x) hint is shown"); }); // ── #156-A10 — undo/redo unifié ───────────────────────────────────────────── await test("undo replays an edit, a clear, then a sort (#156-A10)", () => { const area = mountGrid(); const undoBtn = area.querySelector("#xlsx-undo-btn"); const colA = () => [...area.querySelectorAll('td[data-cell^="A"]')].map((td) => td.textContent); const a1 = area.querySelector('td[data-cell="A1"]'); a1.focus(); editCell(area, "A1", "modifié"); a1.dispatchEvent(new w.Event("blur")); area.querySelector("th.xlsx-rownum").click(); pressKey(area.querySelector(".xlsx-viewer"), "Delete"); openMenuOn(area, "B1"); menuItem(FR["xlsx.sort_desc"]).click(); assert.deepEqual(colA(), ["Cerise", "Abricot", ""], "sorted descending with the cleared row last"); undoBtn.click(); // the sort assert.deepEqual(colA(), ["", "Abricot", "Cerise"], "the row order is restored"); undoBtn.click(); // the clear (B1) assert.equal(area.querySelector('td[data-cell="B1"]').textContent, "3"); undoBtn.click(); // the clear (A1) assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "modifié"); undoBtn.click(); // the edit assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "Banane"); assert.equal(undoBtn.disabled, true, "the whole history is consumed"); }); await test("a row insertion is undoable through the structure endpoint (#156-A10)", async () => { const area = mountGrid(); apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); apiQueue.push({ ok: true, status: 200, body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] }, }); openMenuOn(area, "A2"); menuItem(FR["xlsx.insert_row_above"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.deepEqual(calls[0].body.actions, [{ op: "row_insert", sheet: "Fruits", at: 2, count: 1 }]); assert.equal(area.querySelector("#xlsx-undo-btn").disabled, false, "the history survived the re-render"); area.querySelector("#xlsx-undo-btn").click(); await new Promise((r) => setTimeout(r, 10)); assert.match(calls[2].url, /\/xlsx\/structure/); assert.deepEqual(calls[2].body.actions, [{ op: "row_delete", sheet: "Fruits", at: 2, count: 1 }], "the inverse batch"); }); await test("a destructive structure action is not undoable (#156-A10)", async () => { const area = mountGrid(); apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); apiQueue.push({ ok: true, status: 200, body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] }, }); openMenuOn(area, "A2"); menuItem(FR["xlsx.delete_rows"].replace("{n}", "1")).click(); clickDialog("confirm"); await new Promise((r) => setTimeout(r, 10)); assert.deepEqual(calls[0].body.actions, [{ op: "row_delete", sheet: "Fruits", at: 2, count: 1 }]); assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true, "a delete cannot be replayed"); }); await test("the filter is recorded once and cleared by a single undo (#156-A10)", () => { const area = mountGrid(); const input = area.querySelector("#xlsx-find-input"); const hidden = () => [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display === "none").length; input.value = "ab"; input.dispatchEvent(new w.Event("input", { bubbles: true })); input.value = "abr"; input.dispatchEvent(new w.Event("input", { bubbles: true })); assert.equal(hidden(), 2, "two rows are hidden"); area.querySelector("#xlsx-undo-btn").click(); assert.equal(hidden(), 0, "one undo clears the whole typing session"); assert.equal(input.value, "", "the field follows"); }); // ── #156-A12 — concurrence optimiste (ETag / If-Match) ────────────────────── await test("a save carries the read revision and refreshes it from the response (#156-A12)", async () => { const area = mount({ revision: "rev-1" }); editCell(area, "A1", "42"); apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } }); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 5)); assert.equal(calls[0].body.if_match, "rev-1", "the read token is sent back"); // A second save chains on the revision the first response returned. editCell(area, "A2", "43"); apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-3" } }); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 5)); assert.equal(calls[1].body.if_match, "rev-2"); }); await test("a stale revision is repaired by re-reading before the retry (#156-A12)", async () => { const area = mount({ revision: "rev-1" }); editCell(area, "A1", "42"); // 1) the save is refused as stale, 2) the retry re-reads the file, 3) the // re-send succeeds — all queued up front so the re-read really answers. apiQueue.push({ ok: false, status: 409, body: { detail: "changed on disk", code: "conflict", details: { reason: "stale_revision", current_revision: "rev-9" } }, }); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); const banner = area.querySelector(".xlsx-banner-conflict"); assert.ok(banner, "the conflict banner is shown"); assert.equal(calls[0].body.if_match, "rev-1"); // The retry re-reads the file, then re-sends with the CURRENT revision. apiQueue.push({ ok: true, status: 200, body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_revision: "rev-9", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [] }, }); apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-10" } }); banner.querySelector(".xlsx-banner-retry").click(); await new Promise((r) => setTimeout(r, 10)); assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/); assert.equal(calls[2].body.if_match, "rev-9", "the retry uses the revision just read"); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); }); await test("a structural write carries the revision too (#156-A12)", async () => { const area = mountGrid("rev-1"); apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } }); apiQueue.push({ ok: true, status: 200, body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] }, }); openMenuOn(area, "A2"); menuItem(FR["xlsx.insert_row_above"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.equal(calls[0].body.if_match, "rev-1"); }); // ── #156-A8 — mise en forme en écriture ──────────────────────────────────── /** * Make `ref` the ACTIVE cell: the grid wires setActiveCell() on the cell's * focus event, and td.focus() runs it in JSDOM (a synthetic click would not). */ const activateCell = (area, ref) => { const td = area.querySelector(`td[data-cell="${ref}"]`); td.focus(); return td; }; const openFormatMenu = (area) => { area.querySelector("#xlsx-format-btn").click(); return area.querySelector(".xlsx-format-menu"); }; const formatItem = (area, label) => { const b = [...area.querySelectorAll(".xlsx-format-menu .xlsx-structure-item")] .find((item) => item.textContent === label); assert.ok(b, `format item "${label}" is present`); return b; }; /** Queue the PUT then the re-read the formatting flow performs. */ const queueStyleWrite = () => { apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } }); apiQueue.push({ ok: true, status: 200, body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [], }, }); }; await test("the formatting menu is offered on an editable workbook only (#156-A8)", () => { const area = mountGrid(); assert.ok(area.querySelector("#xlsx-format-btn"), "the button is there"); assert.equal(area.querySelector("#xlsx-format-btn").getAttribute("title"), FR["xlsx.format_btn"]); const csv = mountCsv(); assert.equal(csv.querySelector("#xlsx-format-btn"), null, "no formatting on a csv"); }); await test("bold applied to a selection goes to /xlsx/style and re-renders (#156-A8)", async () => { const area = mountGrid(); dragSelect(area, "A1", "B2"); openFormatMenu(area); queueStyleWrite(); formatItem(area, FR["xlsx.format_bold"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.match(calls[0].url, /\/xlsx\/style\?path=data\.xlsx/); assert.deepEqual(calls[0].body.ops, [ { op: "cell", sheet: "Fruits", range: "A1:B2", style: { bold: true } }, ]); assert.equal(calls[0].body.force, false); assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/, "the viewer re-reads the file"); }); await test("the bold toggle removes an existing bold (#156-A8)", async () => { const area = mountGrid(); activateCell(area, "A1").style.fontWeight = "600"; openFormatMenu(area); queueStyleWrite(); formatItem(area, FR["xlsx.format_bold"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.deepEqual(calls[0].body.ops, [ { op: "cell", sheet: "Fruits", range: "A1", style: { bold: false } }, ]); }); await test("alignment, underline and number formats are written (#156-A8)", async () => { const area = mountGrid(); activateCell(area, "B2"); openFormatMenu(area); queueStyleWrite(); formatItem(area, FR["xlsx.format_align_center"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.deepEqual(calls[0].body.ops[0].style, { align: "center" }); // `calls` keeps accumulating (same test): the second write is calls[2]. const area2 = mountGrid(); activateCell(area2, "B2"); openFormatMenu(area2); queueStyleWrite(); formatItem(area2, FR["xlsx.format_num_percent"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.equal(calls.length, 4, "PUT + re-read for each formatting write"); assert.deepEqual(calls[2].body.ops[0].style, { number_format: "0.0%" }); assert.equal(calls[2].body.ops[0].range, "B2"); }); await test("merging needs a real range (#156-A8)", async () => { const area = mountGrid(); activateCell(area, "A1"); const before = toastCount(); openFormatMenu(area); formatItem(area, FR["xlsx.format_merge"]).click(); await new Promise((r) => setTimeout(r, 5)); assert.equal(calls.length, 0, "nothing is written for a single cell"); assert.equal(toastCount(), before + 1, "the user is told why"); }); await test("the merge action carries the selected range (#156-A8)", async () => { const area = mountGrid(); dragSelect(area, "A1", "B1"); openFormatMenu(area); queueStyleWrite(); formatItem(area, FR["xlsx.format_merge"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.deepEqual(calls[0].body.ops, [{ op: "merge", sheet: "Fruits", range: "A1:B1" }]); }); await test("freezing uses the active cell, unfreezing sends an empty one (#156-A8)", async () => { const area = mountGrid(); activateCell(area, "B3"); openFormatMenu(area); queueStyleWrite(); formatItem(area, FR["xlsx.format_freeze"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.deepEqual(calls[0].body.ops, [{ op: "freeze", sheet: "Fruits", cell: "B3" }]); const area2 = mountGrid(); openFormatMenu(area2); queueStyleWrite(); formatItem(area2, FR["xlsx.format_unfreeze"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.deepEqual(calls[2].body.ops, [{ op: "freeze", sheet: "Fruits", cell: "" }]); }); await test("the column width prompt writes one op per selected column (#156-A8)", async () => { const area = mountGrid(); dragSelect(area, "A1", "B2"); openFormatMenu(area); formatItem(area, FR["xlsx.format_col_width"]).click(); await new Promise((r) => setTimeout(r, 5)); const input = lastDialog()?.querySelector('[data-dialog="input"]'); assert.ok(input, "a themed prompt asks for the width"); input.value = "30"; clickDialog("confirm"); queueStyleWrite(); await new Promise((r) => setTimeout(r, 10)); assert.deepEqual(calls[0].body.ops, [ { op: "col_width", sheet: "Fruits", col: "A", width: 30 }, { op: "col_width", sheet: "Fruits", col: "B", width: 30 }, ]); }); await test("a lossy refusal asks once then retries the style with force (#156-A8)", async () => { const area = mount({ lossy: ["cached_values"] }); activateCell(area, "A1"); openFormatMenu(area); apiQueue.push(lossyError); // 1) the style write is refused as lossy apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } }); // 2) forced retry apiQueue.push({ ok: true, status: 200, body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [], }, }); // 3) the re-read formatItem(area, FR["xlsx.format_italic"]).click(); await new Promise((r) => setTimeout(r, 10)); assert.equal(calls.length, 1, "nothing is retried before the answer"); const dialog = lastDialog(); assert.ok(dialog, "a themed confirmation is shown"); // The 409 payload lists the features (slicers), not the mount's list. assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent); clickDialog("confirm"); await new Promise((r) => setTimeout(r, 10)); const retry = calls.find((c) => c.body && c.body.force === true); assert.ok(retry, "the retry goes out with force"); assert.match(retry.url, /\/xlsx\/style\?path=data\.xlsx/); }); // ── #156-A11 — sortie (CSV/MD/HTML/impression) + recherche classeur ──────── /** * Click an export control and capture the file name and the payload it built. * The real /Blob constructors are swapped out because JSDOM has neither * downloads nor object URLs. */ const captureExport = (area, selector) => { const realCreate = document.createElement.bind(document); const realBlob = globalThis.Blob; const anchor = realCreate("a"); let href = ""; let text = ""; Object.defineProperty(anchor, "href", { set(v) { href = v; }, get: () => href }); document.createElement = (tag) => (tag === "a" ? anchor : realCreate(tag)); URL.createObjectURL = () => "blob:x"; URL.revokeObjectURL = () => {}; globalThis.Blob = class extends realBlob { constructor(parts, opts) { super(parts, opts); text = parts.join(""); } }; try { area.querySelector(selector).click(); } finally { document.createElement = realCreate; globalThis.Blob = realBlob; } return { anchor, text, href }; }; await test("the CSV export carries the selection when a range is active (#156-A11)", () => { const area = mountGrid(); dragSelect(area, "A1", "B2"); const { anchor, text } = captureExport(area, "#xlsx-csv-btn"); assert.equal(anchor.download, "Fruits-A1B2.csv", "the file name pins the range"); assert.equal(text, "\uFEFFBanane;3\nAbricot;10", text); }); await test("without a selection the export still carries the whole sheet (#156-A11)", () => { const area = mountGrid(); const { anchor, text } = captureExport(area, "#xlsx-csv-btn"); assert.equal(anchor.download, "Fruits.csv"); assert.equal(text.split("\n").length, 3, text); assert.ok(text.includes("Cerise"), text); }); await test("the export menu offers Markdown and writes a table (#156-A11)", () => { const area = mountGrid(); area.querySelector("#xlsx-export-btn").click(); assert.equal(area.querySelector("#xlsx-export-menu").style.display, "block"); const { anchor, text } = captureExport(area, '[data-xlsx-export="md"]'); assert.equal(anchor.download, "Fruits.md"); assert.equal(text.split("\n")[0], "| Banane | 3 |"); assert.equal(text.split("\n")[1], "|---|---|"); assert.equal(area.querySelector("#xlsx-export-menu").style.display, "none", "the menu closes"); }); await test("the export menu writes a standalone HTML document (#156-A11)", () => { const area = mountGrid(); area.querySelector("#xlsx-export-btn").click(); const { anchor, text } = captureExport(area, '[data-xlsx-export="html"]'); assert.equal(anchor.download, "Fruits.html"); assert.ok(text.startsWith(""), text.slice(0, 40)); assert.ok(text.includes("Banane"), text); assert.ok(text.includes("Cerise"), text); }); await test("printing builds an off-screen frame and never touches the app chrome (#156-A11)", () => { const area = mountGrid(); area.querySelector("#xlsx-export-btn").click(); area.querySelector('[data-xlsx-export="print"]').click(); const frame = document.querySelector("body > iframe.xlsx-print-frame"); assert.ok(frame, "a print frame is created"); const doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document); assert.ok(doc, "the frame carries its own document"); assert.ok(doc.body.textContent.includes("Banane"), doc.body.textContent); assert.equal(document.body.classList.contains("xlsx-print"), false, "the print class is cleared"); // A second print does not pile frames up. area.querySelector("#xlsx-export-btn").click(); area.querySelector('[data-xlsx-export="print"]').click(); assert.equal(document.querySelectorAll("body > iframe.xlsx-print-frame").length, 1); }); await test("the find scans every sheet and steps across tabs (#156-A11)", () => { const area = document.getElementById("content-area"); area.innerHTML = ""; const oneCell = (value) => '
' + '' + `` + "
AB
1${value}x
"; renderXlsxViewer(area, { vault: "V", path: "data.xlsx", is_xlsx: true, xlsx_sheets: [ { name: "Un", html: oneCell("pomme") }, { name: "Deux", html: oneCell("pomme") }, ], xlsx_lossy_features: [], }); const input = area.querySelector("#xlsx-find-input"); input.value = "pomme"; input.dispatchEvent(new w.Event("input", { bubbles: true })); assert.equal(area.querySelectorAll("mark.xlsx-find-hit").length, 2, "both sheets are scanned"); assert.ok(area.querySelector("#xlsx-find-count").textContent.includes("2 feuilles"), area.querySelector("#xlsx-find-count").textContent); assert.equal(area.querySelector('.xlsx-panel[data-sheet="1"]').style.display, "none", "sheet 2 stays hidden while its hit is not focused"); area.querySelector("#xlsx-find-next").click(); assert.equal(area.querySelector('.xlsx-tab[data-sheet="1"]').classList.contains("active"), true, "the tab of the hit is activated"); assert.equal(area.querySelector('.xlsx-panel[data-sheet="1"]').style.display, "", "the sheet holding the hit is shown"); assert.equal(area.querySelectorAll("mark.xlsx-find-current").length, 1, "exactly one hit is current"); assert.ok(area.querySelector('.xlsx-panel[data-sheet="1"] mark.xlsx-find-current'), "the current hit sits in sheet 2"); }); // ── #179 — menu icons + dismissal at viewer level ───────────────────────── await test("the grid context menu shows one icon per entry (#179-A1)", () => { const area = mount(); const menu = openMenuOn(area, "A1"); assert.ok(menu, "menu opens on right-click"); const items = [...menu.querySelectorAll(".xlsx-context-item")]; assert.ok(items.length > 3, "several entries"); for (const b of items) { const icon = b.querySelector("i[data-lucide]"); assert.ok(icon, `"${b.textContent}" carries an icon`); assert.match(icon.getAttribute("data-lucide"), /^[a-z0-9-]+$/); } assert.equal( menu.querySelector(".xlsx-context-item .xlsx-menu-label").textContent, FR["xlsx.cut"] ); const danger = items.find( (b) => b.textContent === FR["xlsx.delete_rows"].replace("{n}", "1") ); assert.ok(danger && danger.querySelector('i[data-lucide="trash-2"]'), "delete reads as danger"); }); await test("the grid context menu closes when the focus leaves it (#179-A3)", () => { const area = mount(); openMenuOn(area, "A1"); assert.ok(document.querySelector(".xlsx-context-menu"), "menu open"); const first = document.querySelector(".xlsx-context-item"); const outside = document.createElement("button"); document.body.appendChild(outside); first.dispatchEvent(new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside })); assert.equal(document.querySelector(".xlsx-context-menu"), null, "menu closed on focus loss"); outside.remove(); }); await test("the structure menu shows icons and closes on outside click (#179-A2/A3)", () => { const area = mount(); area.querySelector("#xlsx-structure-btn").click(); const menu = area.querySelector(".xlsx-structure-menu"); assert.ok(menu, "structure menu opens"); const icons = [...menu.querySelectorAll(".xlsx-structure-item i[data-lucide]")].map((i) => i.getAttribute("data-lucide") ); assert.ok(icons.includes("file-plus"), "sheet add icon"); assert.ok(icons.includes("trash-2"), "delete icon"); assert.equal( area.querySelector("#xlsx-structure-btn").getAttribute("aria-expanded"), "true" ); document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true })); assert.equal(area.querySelector(".xlsx-structure-menu"), null, "outside click closes"); assert.equal( area.querySelector("#xlsx-structure-btn").getAttribute("aria-expanded"), "false" ); }); await test("the format menu shows icons and closes on Escape (#179-A2/A3)", () => { const area = mount(); area.querySelector("#xlsx-format-btn").click(); const menu = area.querySelector(".xlsx-format-menu"); assert.ok(menu, "format menu opens"); const find = (icon) => menu.querySelector(`.xlsx-structure-item i[data-lucide="${icon}"]`); assert.ok(find("bold"), "bold icon"); assert.ok(find("align-center"), "align icon"); assert.ok(menu.querySelector('.xlsx-format-colour i[data-lucide="palette"]'), "font colour icon"); document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); assert.equal(area.querySelector(".xlsx-format-menu"), null, "Escape closes"); }); await test("the export menu closes on outside click (#179-A3)", () => { const area = mount(); area.querySelector("#xlsx-export-btn").click(); assert.equal(area.querySelector("#xlsx-export-menu").style.display, "block", "menu opens"); document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true })); assert.equal(area.querySelector("#xlsx-export-menu").style.display, "none", "outside click closes"); }); await test("the ribbon save and tab + buttons carry icons (#179-A2)", () => { const area = mount(); assert.equal( area.querySelector('#xlsx-save-btn i[data-lucide]').getAttribute("data-lucide"), "save" ); assert.equal( area.querySelector('#xlsx-tab-add i[data-lucide]').getAttribute("data-lucide"), "plus" ); }); // ── Report ────────────────────────────────────────────────────────────────── console.log(`\n${passCount}/${testCount} tests passed\n`); process.exit(passCount === testCount ? 0 : 1);