Files
ObsiGate/tests/frontend/xlsx-viewer.test.mjs
T
bruno 36b4962efb
CI / lint (push) Successful in 2m42s
CI / security (push) Successful in 1m38s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m12s
feat: tableur mobile — barres repliées par défaut, bascule unique #189
Sous 768 px, la barre de menus, le ruban et le groupe Recherche sont
repliés par défaut : la barre de formule (fx), les onglets et Enregistrer
restent visibles et la grille récupère la hauteur d'écran. Un bouton
unique (#xlsx-tools-toggle) dans la barre des feuilles déplie/replie
l'ensemble (aria-expanded, état actif) ; desktop inchangé.

Ergonomie tactile : cibles ≥ 44 px, onglets en défilement horizontal,
champs de la barre de formule en 16 px (anti-zoom iOS), barre de formule
en 2 lignes quand les outils sont ouverts.

Tests : JSDOM xlsx-viewer.test.mjs (+2, 165/165), E2E mobile (repli par
défaut, dépliage, no-overflow ruban, cibles 44 px) ; openXlsx() déplie
automatiquement ≤ 768 px pour les tests desktop.
2026-10-08 15:45:39 -04:00

2829 lines
131 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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" } };
};
// #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) =>
'<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 = {}, 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) =>
'<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").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 =
'<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: [],
...(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 applies it locally", async () => {
const area = mount();
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
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, 1, "one locked rewrite, applied locally without re-read");
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);
const tab = [...area.querySelectorAll(".xlsx-tab")].find((x) => x.textContent === "Feuille 2");
assert.ok(tab, "the new tab is added locally");
assert.ok(tab.classList.contains("active"), "the new sheet is activated");
});
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
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, 2, "refused write + forced retry, no re-read");
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, 1, "one locked rewrite, applied locally without re-read");
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
const tab = [...area.querySelectorAll(".xlsx-tab")].find((x) => x.textContent === "Feuille 2");
assert.ok(tab, "the new tab is added locally");
});
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("no formulas pill is rendered anymore, on xlsx or csv (#180-A4)", () => {
const area = mount();
assert.equal(area.querySelector(".xlsx-status-formula"), null, "no formula pill on xlsx");
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, "no formula pill on csv");
assert.equal(csvArea.querySelector(".xlsx-status-bar"), null, "no status bar at all without pills");
});
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.equal(area.querySelector(".xlsx-cmd-group[data-group='file']"), null, "no file group left in the ribbon (#180-A3)");
});
// ── #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
openMenuOn(area, "A2");
menuItem(FR["xlsx.insert_row_above"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 1, "one locked rewrite, applied locally without re-read");
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 }]);
assert.equal(area.querySelector('td[data-cell="A2"]').textContent, "", "a blank row is inserted");
assert.equal(area.querySelector('td[data-cell="A3"]').textContent, "A2", "the old row shifts down");
});
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
// #184 — the row spans the whole laid-out grid (columns A → Z).
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:Z1");
});
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 = '<thead><tr><th class="xlsx-corner"></th>' +
cols.map((c) => `<th>${c}</th>`).join("") + "</tr></thead>";
let body = "<tbody>";
for (let r = 1; r <= 6; r++) {
body += `<tr><th class="xlsx-rownum">${r}</th>` +
cols.map((c) => `<td data-cell="${c}${r}">${c}${r}</td>`).join("") + "</tr>";
}
body += "</tbody>";
const html = `<div class="csv-table-wrapper"><table class="csv-table xlsx-table">${head}${body}</table></div>`;
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", "");
// #184 — the row selection spans A → Z, so the clipboard carries 26 cells.
assert.equal(cut.store.text,
["A1", "B1", "C1", "D1", "E1", "F1", ...Array(20).fill("")].join("\t"));
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 = '<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">=SOMME(B1:B2)' +
'<span class="xlsx-cached" data-cached-value="42">42</span></td></tr>' +
"</tbody></table></div>";
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();
// #181-A1 — the first keystroke enters edit mode AND replaces the value, so
// start from a cell whose content survives (A2), then type over it.
const td2 = area.querySelector('td[data-cell="A2"]');
td2.focus();
pressKey(td2, "x");
assert.equal(td2.dataset.editing, "1", "typing enters edit mode");
// JSDOM does not insert the typed character (no browser engine), so put a
// value in place to check that Suppr stays a caret key instead of clearing.
td2.textContent = "Abricot";
pressKey(td2, "Delete");
assert.equal(td2.textContent, "Abricot", "the caret key was not hijacked");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 1, "the selection was not cleared");
});
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 });
// #184 — "the rendered sheet" is the laid-out one: 26 columns × 3 rows.
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:Z3");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 78);
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, "Z3");
});
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, "Z3");
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 = '<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">Autre</td></tr></tbody></table></div>';
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" } }); // insert PUT
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // undo PUT
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 reversible batch is recorded");
assert.equal(area.querySelector('td[data-cell="A3"]').textContent, "Abricot", "the row shifts down locally");
area.querySelector("#xlsx-undo-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.match(calls[1].url, /\/xlsx\/structure/);
assert.deepEqual(calls[1].body.actions, [{ op: "row_delete", sheet: "Fruits", at: 2, count: 1 }], "the inverse batch");
assert.equal(area.querySelector('td[data-cell="A2"]').textContent, "Abricot", "undo restores the rows locally");
});
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 single PUT the formatting flow performs (applied locally). */
const queueStyleWrite = () => {
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } });
};
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 paints locally (#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.equal(calls.length, 1, "one locked rewrite, painted locally without re-read");
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);
for (const ref of ["A1", "B2"]) {
assert.equal(
area.querySelector(`td[data-cell="${ref}"]`).style.fontWeight,
"600",
`${ref} is bold on screen`,
);
}
});
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[1].
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, 2, "one PUT per formatting write");
assert.deepEqual(calls[1].body.ops[0].style, { number_format: "0.0%" });
assert.equal(calls[1].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[1].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 <a>/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 file menu offers Markdown and writes a table (#156-A11, #180-A3)", () => {
const area = mountGrid();
area.querySelector("#xlsx-file-btn").click();
assert.equal(area.querySelector("#xlsx-file-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-file-menu").style.display, "none", "the menu closes");
});
await test("the file menu writes a standalone HTML document (#156-A11)", () => {
const area = mountGrid();
area.querySelector("#xlsx-file-btn").click();
const { anchor, text } = captureExport(area, '[data-xlsx-export="html"]');
assert.equal(anchor.download, "Fruits.html");
assert.ok(text.startsWith("<!DOCTYPE html>"), text.slice(0, 40));
assert.ok(text.includes("<td>Banane</td>"), text);
assert.ok(text.includes("<td>Cerise</td>"), text);
});
await test("printing builds an off-screen frame and never touches the app chrome (#156-A11)", () => {
const area = mountGrid();
area.querySelector("#xlsx-file-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-file-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) =>
'<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">x</td></tr>` +
"</tbody></table></div>";
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 structure and format menus open anchored under their button", () => {
const area = mount();
const btn = area.querySelector("#xlsx-structure-btn");
btn.click();
const menu = area.querySelector(".xlsx-structure-menu");
assert.ok(menu, "structure menu opens");
assert.match(menu.style.top, /px$/, "menu is explicitly positioned vertically");
assert.match(menu.style.left, /px$/, "menu is explicitly positioned horizontally");
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
area.querySelector("#xlsx-format-btn").click();
const fmt = area.querySelector(".xlsx-format-menu");
assert.ok(fmt, "format menu opens");
assert.match(fmt.style.top, /px$/, "format menu is explicitly positioned");
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
});
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 file menu closes on outside click (#179-A3, #180-A3)", () => {
const area = mount();
area.querySelector("#xlsx-file-btn").click();
assert.equal(area.querySelector("#xlsx-file-menu").style.display, "block", "menu opens");
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(area.querySelector("#xlsx-file-menu").style.display, "none", "outside click closes");
});
await test("formulas compute on screen while the bar keeps the raw text", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
const html = '<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">2</td><td data-cell="B1">3</td></tr>' +
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">=A1*10+SOMME(A1:B1)</td><td data-cell="B2">=1/0</td></tr>' +
"</tbody></table></div>";
renderXlsxViewer(area, {
vault: "V", path: "data.xlsx", is_xlsx: true,
xlsx_sheets: [{ name: "F", html }],
xlsx_lossy_features: [],
});
const a2 = area.querySelector('td[data-cell="A2"]');
assert.equal(a2.textContent, "25", "2*10 + (2+3)");
assert.equal(a2.dataset.formula, "=A1*10+SOMME(A1:B1)");
assert.equal(area.querySelector('td[data-cell="B2"]').textContent, "#DIV/0!");
// The formula bar shows the raw formula, not the display value.
a2.focus();
assert.equal(area.querySelector("#xlsx-formula-input").value, "=A1*10+SOMME(A1:B1)");
});
await test("editing a precedent recomputes its dependents", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
const html = '<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">1</td></tr>' +
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">=A1+1</td></tr>' +
"</tbody></table></div>";
renderXlsxViewer(area, {
vault: "V", path: "data.xlsx", is_xlsx: true,
xlsx_sheets: [{ name: "F", html }],
xlsx_lossy_features: [],
});
const a2 = area.querySelector('td[data-cell="A2"]');
assert.equal(a2.textContent, "2");
const a1 = area.querySelector('td[data-cell="A1"]');
a1.textContent = "10";
a1.dispatchEvent(new w.Event("input", { bubbles: true }));
a1.blur();
assert.equal(a2.textContent, "11", "dependent recomputed after commit");
});
await test("saving a formula cell sends the formula, not the display", async () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
const html = '<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">=1+1</td></tr>' +
"</tbody></table></div>";
renderXlsxViewer(area, {
vault: "V", path: "data.xlsx", is_xlsx: true,
xlsx_sheets: [{ name: "F", html }],
xlsx_lossy_features: [],
});
const a1 = area.querySelector('td[data-cell="A1"]');
assert.equal(a1.textContent, "2");
a1.textContent = "3";
a1.dispatchEvent(new w.Event("input", { bubbles: true }));
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 5));
assert.match(calls[0].url, /\/xlsx\/save/);
assert.deepEqual(calls[0].body.cells, { A1: "3" });
// Now type a formula and save: the payload carries the raw formula.
a1.textContent = "=2+2";
a1.dispatchEvent(new w.Event("input", { bubbles: true }));
a1.blur();
assert.equal(a1.textContent, "4", "computed on commit");
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 5));
assert.deepEqual(calls[1].body.cells, { A1: "=2+2" });
});
await test("an unknown function defers to Excel's cached shadow", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
const html = '<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">=RECHERCHEV(A2;A:B)' +
'<span class="xlsx-cached" data-cached-value="1">42</span></td></tr>' +
"</tbody></table></div>";
renderXlsxViewer(area, {
vault: "V", path: "data.xlsx", is_xlsx: true,
xlsx_sheets: [{ name: "F", html }],
xlsx_lossy_features: [],
});
const a1 = area.querySelector('td[data-cell="A1"]');
assert.ok(a1.querySelector(".xlsx-cached"), "shadow kept instead of #NOM?");
assert.equal(a1.dataset.formula, undefined, "not engine-managed");
});
await test("=LIEN renders a real anchor and comments show a marker", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
const html = '<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">=LIEN("https://x.test";"ici")</td></tr>' +
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">note</td></tr>' +
"</tbody></table></div>";
renderXlsxViewer(area, {
vault: "V", path: "data.xlsx", is_xlsx: true,
xlsx_sheets: [{ name: "F", html, comments: { A2: "à relire" } }],
xlsx_lossy_features: [],
});
const a = area.querySelector('td[data-cell="A1"] a.xlsx-formula-link');
assert.ok(a, "anchor rendered");
assert.equal(a.getAttribute("href"), "https://x.test");
assert.equal(a.textContent, "ici");
const dot = area.querySelector('td[data-cell="A2"] .xlsx-comment-dot');
assert.ok(dot, "comment marker rendered");
assert.equal(dot.title, "à relire");
});
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"
);
});
// ── Sheets-like ribbon ────────────────────────────────────────────────────
await test("the ribbon exposes the Sheets controls with stable ids", () => {
const area = mountGrid();
for (const id of [
"xs-search", "xs-print", "xs-paint", "xs-zoom", "xs-cur", "xs-pct",
"xs-decminus", "xs-decplus", "xs-num123", "xs-numfmt", "xs-fsminus",
"xs-fssize", "xs-fsplus", "xs-b", "xs-i", "xs-s", "xs-u", "xs-fgcolor",
"xs-bgcolor", "xs-borders", "xs-merge", "xs-alignh", "xs-alignv",
"xs-wrap", "xs-rot", "xs-more", "xlsx-save-btn", "xlsx-formula-btn",
]) {
assert.ok(area.querySelector(`#${id}`), `#${id} present`);
}
});
await test("the B toggle writes bold for the active cell", async () => {
const area = mountGrid();
activateCell(area, "A1");
queueStyleWrite();
area.querySelector("#xs-b").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", style: { bold: true } },
]);
});
await test("the borders menu writes all borders", async () => {
const area = mountGrid();
activateCell(area, "A1");
queueStyleWrite();
area.querySelector("#xs-borders").click();
const item = [...area.querySelectorAll(".xlsx-structure-item")]
.find((b) => b.textContent === FR["xlsx.border_all"]);
assert.ok(item, "border entry present with an icon");
assert.ok(item.querySelector("i[data-lucide]"), "border entry iconised");
item.click();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.ops, [
{ op: "cell", sheet: "Fruits", range: "A1", style: { border: "all" } },
]);
assert.equal(area.querySelector(".xlsx-structure-menu"), null, "menu dismissed after pick");
});
await test("zoom scales the panels and persists", () => {
const area = mountGrid();
const sel = area.querySelector("#xs-zoom");
sel.value = "150";
sel.dispatchEvent(new w.Event("change", { bubbles: true }));
assert.equal(area.querySelector(".xlsx-panels").style.zoom, "150%");
assert.equal(w.localStorage.getItem("obsigate_xlsx_zoom"), "150");
sel.value = "100";
sel.dispatchEvent(new w.Event("change", { bubbles: true }));
assert.equal(area.querySelector(".xlsx-panels").style.zoom, "");
});
await test("fewer decimals rewrites the number format", async () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
const html = '<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">3.50</td></tr>' +
"</tbody></table></div>";
renderXlsxViewer(area, {
vault: "V", path: "data.xlsx", is_xlsx: true,
xlsx_sheets: [{ name: "F", html, formats: { A1: "0.00" } }],
xlsx_lossy_features: [],
});
activateCell(area, "A1");
queueStyleWrite();
area.querySelector("#xs-decminus").click();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.ops, [
{ op: "cell", sheet: "F", range: "A1", style: { number_format: "0.0" } },
]);
});
await test("merge fuses the selected range", async () => {
const area = mountGrid();
dragSelect(area, "A1", "B2");
queueStyleWrite();
area.querySelector("#xs-merge").click();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.ops, [
{ op: "merge", sheet: "Fruits", range: "A1:B2" },
]);
});
await test("the mini-bar shows on ranges and hides on single cells", () => {
const area = mountGrid();
assert.equal(area.querySelector("#xlsx-minibar").hidden, true);
dragSelect(area, "A1", "B2");
const bar = area.querySelector("#xlsx-minibar");
assert.equal(bar.hidden, false, "visible on a range");
assert.ok(bar.style.left.endsWith("px") && bar.style.top.endsWith("px"), "positioned");
activateCell(area, "A1");
assert.equal(area.querySelector("#xlsx-minibar").hidden, true, "hidden on a single cell");
});
await test("paint copies the source style onto the target", async () => {
const area = mountGrid();
activateCell(area, "A1").style.fontWeight = "600";
queueStyleWrite();
area.querySelector("#xs-paint").click();
assert.ok(area.querySelector("#xs-paint").classList.contains("active"), "paint armed");
activateCell(area, "B2");
area.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
assert.match(calls[0].url, /\/xlsx\/style\?path=data\.xlsx/);
// #184-A1 — the paint tool carries the source's COMPLETE format (a source
// with no explicit format resets the target instead of doing nothing).
assert.deepEqual(calls[0].body.ops, [
{ op: "cell", sheet: "Fruits", range: "B2", style: {
bold: true, italic: false, underline: false, strike: false,
font_color: "", fill_color: "", font_size: 11, align: "left",
wrap: false, rotation: 0, number_format: "General", border: "none",
} },
]);
assert.ok(!area.querySelector("#xs-paint").classList.contains("active"), "paint disarmed");
});
await test("a comment round-trips through the overflow menu", async () => {
const area = mountGrid();
activateCell(area, "B2");
queueStyleWrite();
area.querySelector("#xs-more").click();
const item = [...area.querySelectorAll(".xlsx-structure-item")]
.find((b) => b.textContent === FR["xlsx.comment_add"]);
assert.ok(item, "comment entry present");
item.click();
await new Promise((r) => setTimeout(r, 5));
lastDialog().querySelector('[data-dialog="input"]').value = "À relire";
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.ops, [
{ op: "comment_set", sheet: "Fruits", ref: "B2", text: "À relire" },
]);
});
await test("SUM inserts a range total above the active cell", () => {
const area = mountGrid();
dragSelect(area, "B3", "B1");
area.querySelector('#xlsx-minibar [data-mini="sum"]').click();
const b3 = area.querySelector('td[data-cell="B3"]');
assert.equal(b3.textContent, "13", "3 + 10 recomputed below");
assert.ok(b3.classList.contains("xlsx-dirty"), "inserted formula is dirty");
});
await test("dragging a column edge writes its width", async () => {
const area = mountGrid();
queueStyleWrite();
const th = area.querySelectorAll(".xlsx-table thead th")[1];
th.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 5, clientY: 5 }));
document.dispatchEvent(new w.MouseEvent("mousemove", { bubbles: true, clientX: 60, clientY: 5 }));
document.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
assert.match(calls[0].url, /\/xlsx\/style\?path=data\.xlsx/);
assert.equal(calls[0].body.ops[0].op, "col_width");
assert.equal(calls[0].body.ops[0].col, "A");
});
await test("a plain header click selects without writing", () => {
const area = mountGrid();
area.querySelectorAll(".xlsx-table thead th")[1].click();
assert.equal(calls.length, 0, "no style write without a drag");
});
// ── #180 — sheet strip, file menu, resize cursor, themed grid ──────────────
await test("the sheet strip holds the tabs and Save on the right (#180-A3, #185)", () => {
const area = mount();
const strip = area.querySelector(".xlsx-sheetbar");
assert.ok(strip, "sheet strip rendered");
assert.ok(strip.querySelector(".xlsx-tabs"), "tabs live in the strip");
const end = strip.querySelector(".xlsx-sheetbar-end");
assert.ok(end, "right-end slot rendered");
assert.ok(end.querySelector("#xlsx-save-btn"), "save pinned on the right");
// #185 — the file outputs left the strip for the Fichier menu of the menu bar.
assert.equal(end.querySelector("#xlsx-file-btn"), null, "no file dropdown on the strip");
assert.equal(area.querySelector(".xlsx-cmdbar .xlsx-tabs"), null, "tabs left the ribbon");
});
await test("the Fichier menu groups Download, CSV, Markdown, HTML and Print (#185)", () => {
const area = mount();
const btn = area.querySelector("#xlsx-file-btn");
assert.equal(btn.textContent.trim(), FR["xlsx.mbar_file"], "the menu bar owns the File menu");
btn.click();
const menu = area.querySelector("#xlsx-file-menu");
assert.equal(menu.style.display, "block", "menu opens");
assert.ok(menu.querySelector("#xlsx-download-btn"), "download entry kept its id");
assert.ok(menu.querySelector("#xlsx-csv-btn"), "csv entry kept its id");
assert.equal(
menu.querySelector("#xlsx-csv-btn .xlsx-menu-label").textContent,
FR["xlsx.export_csv"]
);
assert.deepEqual(
[...menu.querySelectorAll("[data-xlsx-export]")].map((n) => n.dataset.xlsxExport),
["md", "html", "print"]
);
// Toggle: a second click closes.
btn.click();
assert.equal(menu.style.display, "none", "toggle closes");
});
await test("hovering a column edge shows the col-resize icon (#180-A1)", () => {
const area = mount();
const table = area.querySelector(".xlsx-table");
// JSDOM zeroes every rect, so clientX=5 lands within 8px of each header's
// right edge — exactly the resize zone.
table.dispatchEvent(new w.MouseEvent("mousemove", { bubbles: true, clientX: 5, clientY: 5 }));
assert.equal(table.style.cursor, "col-resize", "table cursor");
assert.ok(table.classList.contains("xlsx-resize-col"), "resize class paints the cells");
const edgeTh = area.querySelectorAll(".xlsx-table thead th")[1];
assert.equal(edgeTh.style.cursor, "col-resize", "edge header mirrors the icon");
// Far from any edge the cursors go back to normal.
table.dispatchEvent(new w.MouseEvent("mousemove", { bubbles: true, clientX: 500, clientY: 500 }));
assert.equal(table.style.cursor, "");
assert.ok(!table.classList.contains("xlsx-resize-col"));
assert.ok(!table.classList.contains("xlsx-resize-row"));
});
await test("hovering a row edge shows the row-resize icon (#180-A1)", () => {
const area = mount();
const table = area.querySelector(".xlsx-table");
table.dispatchEvent(new w.MouseEvent("mousemove", { bubbles: true, clientX: 500, clientY: 5 }));
assert.equal(table.style.cursor, "row-resize", "table cursor");
assert.ok(table.classList.contains("xlsx-resize-row"), "resize class paints the cells");
const edgeTh = area.querySelector("th.xlsx-rownum");
assert.equal(edgeTh.style.cursor, "row-resize", "edge row header mirrors the icon");
table.dispatchEvent(new w.MouseEvent("mouseleave", { bubbles: true }));
assert.equal(table.style.cursor, "");
assert.equal(edgeTh.style.cursor, "pointer", "header cursor restored");
});
await test("the grid tokens follow the active theme, all modes included (#180-A2)", async () => {
const { applyTheme } = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "themes.js")).href
);
const cssVar = (name) =>
document.documentElement.style.getPropertyValue(name).trim();
applyTheme("spotify", "dark");
assert.equal(cssVar("--grid-bg"), "#121212", "grid ground = theme surface");
assert.equal(cssVar("--grid-header-bg"), "#1a1a1a", "header = theme hover");
assert.equal(cssVar("--grid-header-text"), "#ffffff", "header text = theme text");
// #185 — one uniform, legible line colour for the WHOLE grid: a blend of the
// text colour into the sheet surface, not `--border` (which was invisible).
assert.ok(cssVar("--grid-border").startsWith("color-mix(in srgb, #ffffff"),
`grid line = ink over surface (${cssVar("--grid-border")})`);
assert.ok(cssVar("--grid-border").includes("#121212"), "the grid surface closes the blend");
assert.ok(cssVar("--grid-zebra").includes("color-mix"), "zebra stays translucent");
applyTheme("notion", "light");
assert.equal(cssVar("--grid-bg"), "#f7f6f3", "light theme surface");
applyTheme("github", "high-contrast");
assert.equal(cssVar("--grid-bg"), "#0a0a0a", "generated high-contrast surface");
applyTheme("github", "sepia");
assert.equal(cssVar("--grid-bg"), "#eee3c8", "generated sepia surface");
});
// ── #181 — modèle de saisie Google Sheets (sélection ≠ édition) ─────────────
await test("a plain click or arrow SELECTS the cell without an editing caret (#181-A1)", () => {
const area = mountGrid();
const td = area.querySelector('td[data-cell="A1"]');
td.focus();
assert.equal(td.dataset.editing, "", "focus alone never edits");
assert.ok(!td.classList.contains("xlsx-editing"), "no editing class");
assert.ok(td.classList.contains("xlsx-cell"), "the cell is styled as a cell");
// Every rendered cell starts selected, never editing.
assert.equal(area.querySelectorAll("td.xlsx-editing").length, 0, "no cell opens in edit mode");
});
await test("double-click switches the cell to editing and keeps the caret (#181-A1)", () => {
const area = mountGrid();
const td = area.querySelector('td[data-cell="A1"]');
td.focus();
assert.equal(td.dataset.editing, "", "selected first");
td.dispatchEvent(new w.MouseEvent("dblclick", { bubbles: true, clientX: 5, clientY: 5 }));
assert.equal(td.dataset.editing, "1", "double-click enters editing");
assert.ok(td.classList.contains("xlsx-editing"), "the editing class is set");
assert.equal(td.textContent, "Banane", "double-click edits in place, it does not clear");
});
await test("the first keystroke on a selected cell REPLACES its content (#181-A1)", () => {
const area = mountGrid();
const td = area.querySelector('td[data-cell="A1"]');
td.focus();
assert.equal(td.textContent, "Banane");
pressKey(td, "x");
assert.equal(td.dataset.editing, "1", "typing enters edit mode");
assert.equal(td.textContent, "", "the previous value is replaced, not appended to");
assert.ok(td.classList.contains("xlsx-dirty"), "the cell is unsaved work");
});
await test("a second keystroke does not wipe what is being typed (#181-A1)", () => {
const area = mountGrid();
const td = area.querySelector('td[data-cell="A1"]');
td.focus();
pressKey(td, "x");
td.textContent = "xy";
pressKey(td, "z");
assert.equal(td.textContent, "xy", "only the FIRST keystroke replaces the value");
});
await test("Enter on a selected cell switches it to EDITING mode (#181-A3)", () => {
const area = mountGrid();
const td = area.querySelector('td[data-cell="A1"]');
td.focus();
assert.equal(td.dataset.editing, "", "starts selected");
pressKey(td, "Enter");
assert.equal(td.dataset.editing, "1", "Enter enters editing mode");
assert.equal(document.activeElement, td, "the focus stays on the edited cell");
assert.equal(td.textContent, "Banane", "entering edit mode does not touch the value");
});
await test("Enter while editing commits and moves to the cell BELOW (#181-A3)", () => {
const area = mountGrid();
const td = area.querySelector('td[data-cell="A1"]');
td.focus();
pressKey(td, "x"); // edit mode, value cleared
td.textContent = "12";
pressKey(td, "Enter");
assert.equal(td.dataset.editing, "", "the edited cell leaves edit mode");
const below = area.querySelector('td[data-cell="A2"]');
assert.equal(document.activeElement, below, "focus moved to the cell below");
assert.equal(below.dataset.editing, "", "the new cell is SELECTED, not editing");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A2", "the Name Box follows");
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "12", "the value was committed");
});
await test("Shift+arrows extend a range with a visible outline (#181-A2)", () => {
const area = mountGrid();
area.querySelector('td[data-cell="A1"]').focus();
// Each extension starts from the cell the focus actually landed on (the
// browser sends the next keydown there, as a real user would).
const press = (key) =>
pressKey(document.activeElement, key, { shiftKey: true });
press("ArrowDown");
press("ArrowRight");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B2", "the Name Box shows the range");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4, "the 4 cells are selected");
// The outline traces the block: each cell carries the edges it is on.
const edge = (ref, cls) =>
Boolean(area.querySelector(`td[data-cell="${ref}"]`).classList.contains(cls));
assert.ok(edge("A1", "xlsx-sel-top") && edge("A1", "xlsx-sel-left"), "anchor: top + left");
assert.ok(edge("B1", "xlsx-sel-top") && edge("B1", "xlsx-sel-right"), "B1: top + right");
assert.ok(edge("A2", "xlsx-sel-bottom") && edge("A2", "xlsx-sel-left"), "A2: bottom + left");
assert.ok(edge("B2", "xlsx-sel-bottom") && edge("B2", "xlsx-sel-right"), "B2: bottom + right");
// Interior-only classes must not leak on the corners.
assert.ok(!edge("B1", "xlsx-sel-left"), "B1 is not on the left edge");
// The covered headers are highlighted too.
assert.ok(area.querySelector('.xlsx-table thead th.xlsx-hdr-selected'), "column header A/B highlighted");
assert.equal(area.querySelectorAll("th.xlsx-rownum.xlsx-hdr-selected").length, 2, "rows 1 and 2 highlighted");
});
await test("dragging with the mouse paints the same range outline (#181-A2)", () => {
const area = mountGrid();
const a1 = area.querySelector('td[data-cell="A1"]');
a1.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
const b2 = area.querySelector('td[data-cell="B2"]');
b2.dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true }));
// The release lands on the cell (the drag listener is on the viewer).
b2.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4, "the block is selected");
assert.ok(b2.classList.contains("xlsx-sel-bottom") && b2.classList.contains("xlsx-sel-right"),
"the dragged corner carries both edges");
assert.ok(area.querySelector(".xlsx-viewer").classList.contains("xlsx-selecting") === false,
"the selecting flag is cleared on release");
});
await test("a plain click after editing drops the caret and keeps the cell selected (#181-A1)", () => {
const area = mountGrid();
const a1 = area.querySelector('td[data-cell="A1"]');
a1.focus();
pressKey(a1, "x"); // editing
assert.equal(a1.dataset.editing, "1");
const b3 = area.querySelector('td[data-cell="B3"]');
b3.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
document.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
assert.equal(a1.dataset.editing, "", "the previously edited cell left edit mode");
assert.ok(!a1.classList.contains("xlsx-editing"), "no editing class left behind");
});
await test("Escape leaves editing and restores the value (#181-A1)", () => {
const area = mountGrid();
const td = area.querySelector('td[data-cell="A1"]');
td.focus();
pressKey(td, "x");
td.textContent = "perdu";
pressKey(td, "Escape");
assert.equal(td.textContent, "Banane", "the original value is restored");
assert.equal(td.dataset.editing, "", "edit mode is left");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0, "the cell is clean again");
});
// ── #182 — désélection & couleurs sur une plage ────────────────────────────
await test("a plain click always collapses a stuck range (#182)", () => {
const area = mountGrid();
area.querySelector('td[data-cell="A1"]').focus();
const press = (k) => pressKey(document.activeElement, k, { shiftKey: true });
press("ArrowDown");
press("ArrowRight");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4, "the range is selected");
// A Shift+click on an already-focused cell used to leave the range stuck:
// `preventDefault()` blocked the focus move, so the next click on that cell
// fired no focus event and the range refused to clear.
const b2 = area.querySelector('td[data-cell="B2"]');
b2.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0, shiftKey: true }));
b2.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4, "the Shift+click keeps the 2x2 range");
// Now a plain click on the cell that still holds the focus must collapse it.
const a1 = area.querySelector('td[data-cell="A1"]');
a1.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
a1.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 1, "the range collapsed");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1", "the Name Box follows");
});
await test("a drag keeps the range in the Name Box after the release (#182)", () => {
const area = mountGrid();
const a1 = area.querySelector('td[data-cell="A1"]');
a1.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
const b2 = area.querySelector('td[data-cell="B2"]');
b2.dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true }));
b2.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4, "the block stays selected");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B2",
"the Name Box shows the range, not just the anchor");
});
await test("text and fill colours apply to every cell of a selection (#182)", async () => {
const area = mountGrid();
area.querySelector('td[data-cell="A1"]').focus();
const press = (k) => pressKey(document.activeElement, k, { shiftKey: true });
press("ArrowDown");
press("ArrowRight");
// #183 — the ribbon swatches open the Sheets-like colour panel; the colour
// comes from its palette (no native <input type="color"> any more).
const pick = async (btnId, hex) => {
area.querySelector(`#${btnId}`).click();
const panels = document.querySelectorAll(".xlsx-colour-panel");
const swatch = [...panels[panels.length - 1].querySelectorAll(".xlsx-colour-swatch-btn")]
.find((b) => b.dataset.colour === hex);
assert.ok(swatch, `${btnId}: palette carries ${hex}`);
swatch.click();
await new Promise((r) => setTimeout(r, 10));
};
await pick("xs-fgcolor", "#d93025");
await pick("xs-bgcolor", "#f4b400");
const styleWrite = calls.filter((c) => c.url.includes("/xlsx/style"));
assert.equal(styleWrite.length, 2, "one style write per colour");
assert.deepEqual(styleWrite[0].body.ops, [{
op: "cell", sheet: "Fruits", range: "A1:B2", style: { font_color: "#d93025" },
}]);
assert.deepEqual(styleWrite[1].body.ops, [{
op: "cell", sheet: "Fruits", range: "A1:B2", style: { fill_color: "#f4b400" },
}]);
// Every cell of the range carries both colours.
for (const ref of ["A1", "B1", "A2", "B2"]) {
const style = area.querySelector(`td[data-cell="${ref}"]`).getAttribute("style");
assert.match(style, /color:#d93025/, `${ref} font colour`);
assert.match(style, /background:#f4b400/, `${ref} fill colour`);
}
// The swatches on the buttons mirror the chosen colours.
const fgSwatch = area.querySelector('[data-swatch-for="xs-fgcolor"]');
const bgSwatch = area.querySelector('[data-swatch-for="xs-bgcolor"]');
assert.equal(fgSwatch.style.background, "rgb(217, 48, 37)", fgSwatch.style.background);
assert.equal(bgSwatch.style.background, "rgb(244, 180, 0)", bgSwatch.style.background);
});
// ── #182b — le contour suit la sélection, sans.framework résiduel ───────────
await test("the range outline is cleared, never piled up (#182)", () => {
const area = mountGrid();
const edgesOf = (ref) => {
const td = area.querySelector(`td[data-cell="${ref}"]`);
return ["xlsx-sel-top", "xlsx-sel-bottom", "xlsx-sel-left", "xlsx-sel-right"]
.filter((c) => td.classList.contains(c));
};
area.querySelector('td[data-cell="A1"]').focus();
assert.equal(edgesOf("A1").length, 4, "the focused cell is boxed");
// Walk the grid: every cell but the focused one must be COMPLETELY clean,
// otherwise the outlines accumulate (the selector used to be missing its
// leading dot and matched nothing, so nothing was ever cleared).
for (const k of ["ArrowRight", "ArrowRight", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowUp"]) {
pressKey(document.activeElement, k);
}
const EDGE_CLASSES = ["xlsx-sel-top", "xlsx-sel-bottom", "xlsx-sel-left", "xlsx-sel-right"];
const outlined = [...area.querySelectorAll("td[data-cell]")]
.filter((td) => EDGE_CLASSES.some((c) => td.classList.contains(c)))
.map((td) => td.dataset.cell);
assert.deepEqual(outlined, [document.activeElement.dataset.cell],
"only the focused cell keeps an outline");
for (const cls of EDGE_CLASSES) {
assert.equal(area.querySelectorAll(`.${cls}`).length, 1, `exactly one "${cls}" in the grid`);
}
});
await test("a range outline is replaced, not merged with the previous one (#182)", () => {
const area = mountGrid();
area.querySelector('td[data-cell="A1"]').focus();
const press = (k) => pressKey(document.activeElement, k, { shiftKey: true });
press("ArrowRight"); // A1:B1
press("ArrowDown"); // A1:B2
assert.equal(area.querySelectorAll(".xlsx-sel-top").length, 2, "two top edges for two columns");
// Collapse back to a single cell: the other edges must go.
area.querySelector('td[data-cell="A1"]').dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
assert.equal(area.querySelectorAll(".xlsx-sel-top").length, 1, "one top edge again");
assert.equal(area.querySelectorAll(".xlsx-sel-bottom").length, 1, "one bottom edge again");
assert.equal(area.querySelectorAll(".xlsx-sel-right").length, 1, "one right edge again");
});
await test("an arrow during editing commits and moves the selection (#182)", () => {
const area = mountGrid();
const td = area.querySelector('td[data-cell="A1"]');
td.focus();
pressKey(td, "x"); // editing, value replaced
td.textContent = "12";
pressKey(td, "ArrowDown");
assert.equal(td.dataset.editing, "", "the edited cell left edit mode");
assert.equal(td.textContent, "12", "the value was committed");
const below = area.querySelector('td[data-cell="A2"]');
assert.equal(document.activeElement, below, "the focus moved to the cell below");
assert.ok(below.classList.contains("xlsx-active"), "the outline follows the new cell");
assert.ok(!td.classList.contains("xlsx-active"), "the previous cell is no longer outlined");
});
// ── #185 — barre de menus Google Sheets + boutons du ruban ──────────────────
await test("the viewer ships the ten Sheets menus above the ribbon (#185)", () => {
const area = mount();
const bar = area.querySelector(".xlsx-menubar");
assert.ok(bar, "menu bar rendered");
const labels = [...bar.querySelectorAll(".xlsx-menubtn")].map((b) => b.textContent.trim());
assert.deepEqual(labels, [
FR["xlsx.mbar_file"], FR["xlsx.mbar_edit"], FR["xlsx.mbar_view"],
FR["xlsx.mbar_insert"], FR["xlsx.mbar_format"], FR["xlsx.mbar_data"],
FR["xlsx.mbar_tools"], FR["xlsx.mbar_gemini"],
FR["xlsx.mbar_extensions"], FR["xlsx.mbar_help"],
]);
// The bar sits first inside the toolbar, i.e. above the buttons.
const toolbar = area.querySelector(".xlsx-toolbar");
assert.equal(toolbar.firstElementChild.className, "xlsx-menubar");
assert.ok(area.querySelector(".xlsx-cmdbar"), "the ribbon is still there, below the menus");
});
await test("every menu entry is wired, or says plainly that ObsiGate has none (#185)", () => {
const area = mount();
const rows = [...area.querySelectorAll(".xlsx-menu-item")];
assert.ok(rows.length > 120, `all the Sheets entries are listed (${rows.length})`);
const unavailable = rows.filter((r) => r.hasAttribute("data-xlsx-unavailable"));
assert.ok(unavailable.length > 0, "the honest dead ends are still listed");
// Clicking one answers with the localized message instead of doing nothing.
const label = unavailable[0].querySelector(".xlsx-menu-label").textContent;
unavailable[0].click();
const toasts = [...document.querySelectorAll(".toast")].map((n) => n.textContent);
assert.ok(
toasts.some((x) => x.includes(label)),
`the unavailable entry explains itself (${toasts.join(" | ")})`,
);
assert.equal(calls.length, 0, "and nothing was sent to the server");
});
await test("Affichage ▸ Show toggles the formula bar, the grid and the headers (#185)", () => {
const area = mountGrid();
const viewer = area.querySelector(".xlsx-viewer");
const bar = area.querySelector(".xlsx-formula-bar");
area.querySelector('[data-xlsx-action="toggle-formula-bar"]').click();
assert.equal(bar.hidden, true, "formula bar hidden");
area.querySelector('[data-xlsx-action="toggle-formula-bar"]').click();
assert.equal(bar.hidden, false, "formula bar back");
area.querySelector('[data-xlsx-action="toggle-grid"]').click();
assert.ok(viewer.classList.contains("xlsx-no-grid"), "gridlines off");
assert.equal(area.querySelector("#xs-grid").getAttribute("aria-pressed"), "false");
area.querySelector('#xs-grid').click();
assert.ok(!viewer.classList.contains("xlsx-no-grid"), "the ribbon switch is in step");
area.querySelector('[data-xlsx-action="toggle-headers"]').click();
assert.ok(viewer.classList.contains("xlsx-no-headers"), "headers off");
area.querySelector('[data-xlsx-action="toggle-headers"]').click();
assert.ok(!viewer.classList.contains("xlsx-no-headers"), "headers back");
});
await test("Affichage ▸ Zoom reuses the ribbon's zoom (#185)", () => {
const area = mountGrid();
area.querySelector('[data-xlsx-action="zoom"][data-xlsx-arg="125"]').click();
assert.equal(area.querySelector("#xs-zoom").value, "125");
assert.equal(area.querySelector(".xlsx-panels").style.zoom, "125%");
area.querySelector('[data-xlsx-action="zoom"][data-xlsx-arg="100"]').click();
assert.equal(area.querySelector(".xlsx-panels").style.zoom, "", "back to 100%");
});
await test("Insertion ▸ rows writes the structure op (#185)", async () => {
const area = mountGrid();
activateCell(area, "A2");
area.querySelector('[data-xlsx-action="insert-rows-above"]').click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 1, "one locked rewrite");
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.actions, [
{ op: "row_insert", sheet: "Fruits", at: 2, count: 1 },
]);
});
await test("Format ▸ Nombre writes the number format (#185)", async () => {
const area = mountGrid();
queueStyleWrite();
activateCell(area, "A1");
area.querySelector('[data-xlsx-action="numfmt"][data-xlsx-arg="0.0%"]').click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 1);
assert.match(calls[0].url, /\/xlsx\/style\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.ops[0].style, { number_format: "0.0%" });
});
await test("Format ▸ Banded colours alternates the fill of the selection (#185)", async () => {
const area = mountGrid();
dragSelect(area, "A1", "B3");
queueStyleWrite();
area.querySelector('[data-xlsx-action="banded"]').click();
await new Promise((r) => setTimeout(r, 20));
const writes = calls.filter((c) => String(c.url).includes("/xlsx/style"));
assert.equal(writes.length, 1, "one batched write");
const ops = writes[0].body.ops;
assert.equal(ops.length, 3, "one op per row of the selection");
assert.equal(ops[0].range, "A1:B1");
assert.equal(ops[2].range, "A3:B3");
assert.ok(ops[0].style.fill_color, "the first row is shaded");
assert.equal(ops[1].style.fill_color, "", "the second row is cleared");
assert.ok(ops[2].style.fill_color, "the third row is shaded again");
});
await test("Format ▸ Thème fills its section on demand and applies a theme (#185)", async () => {
const area = mountGrid();
const fmt = area.querySelector('[data-xlsx-menu="format"]');
fmt.click();
const drop = area.querySelector("#xlsx-menu-format");
const theme = drop.querySelector('[data-xlsx-action="theme-menu"]');
assert.equal(theme.nextElementSibling.children.length, 0, "empty section");
theme.click();
await new Promise((r) => setTimeout(r, 60));
const themes = drop.querySelectorAll('[data-xlsx-action="apply-theme"]');
assert.ok(themes.length >= 10, `the theme list is built (${themes.length})`);
assert.equal(theme.getAttribute("aria-expanded"), "true");
assert.equal(drop.style.display, "block", "the menu stays open while choosing");
themes[0].click();
await new Promise((r) => setTimeout(r, 40));
assert.equal(drop.style.display, "none", "picking a theme closes the menu");
assert.ok(
document.documentElement.style.getPropertyValue("--bg-primary"),
"the theme was applied to the document",
);
});
await test("Outils ▸ Accessibilité toggles the enhanced grid (#185)", () => {
const area = mountGrid();
const viewer = area.querySelector(".xlsx-viewer");
area.querySelector('[data-xlsx-action="a11y"]').click();
assert.ok(viewer.classList.contains("xlsx-a11y"));
area.querySelector('[data-xlsx-action="a11y"]').click();
assert.ok(!viewer.classList.contains("xlsx-a11y"));
});
await test("Aide ▸ Raccourcis clavier lists them in a dialog (#185)", async () => {
const area = mountGrid();
area.querySelector('[data-xlsx-action="shortcuts"]').click();
await new Promise((r) => setTimeout(r, 10));
const dialog = lastDialog();
assert.ok(dialog, "a dialog opened");
assert.ok(dialog.textContent.includes(FR["xlsx.shortcuts_title"]), FR["xlsx.shortcuts_title"]);
assert.ok(dialog.textContent.includes("Ctrl"), "the shortcuts are listed");
clickDialog("confirm");
});
await test("a read-only workbook keeps the view menus but drops the editing ones (#185)", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
renderXlsxViewer(area, {
vault: "V", path: "old.xls", is_xlsx: true, xlsx_readonly: true,
xlsx_sheets: [{ name: "F", html: sheetHtml("a") }],
});
assert.equal(area.querySelector('[data-xlsx-action="bold"]'), null, "no Bold");
assert.equal(area.querySelector('[data-xlsx-action="insert-cells"]'), null, "no Insert ▸ Cells");
assert.ok(area.querySelector('[data-xlsx-action="copy"]'), "Copy stays");
assert.ok(area.querySelector('[data-xlsx-action="toggle-grid"]'), "Gridlines stay");
});
// ── #189 — mise en page mobile : barres repliées derrière un basculeur ──────
await test("the sheet strip carries the tools toggle that flips the toolbar (#189)", () => {
const area = mountGrid();
const strip = area.querySelector(".xlsx-sheetbar");
const toggle = area.querySelector("#xlsx-tools-toggle");
assert.ok(toggle, "toggle rendered");
assert.equal(toggle.closest(".xlsx-sheetbar"), strip, "it lives in the sheet strip");
assert.equal(toggle.getAttribute("aria-expanded"), "false", "starts collapsed");
const toolbar = area.querySelector(".xlsx-toolbar");
assert.ok(!toolbar.classList.contains("xlsx-tools-open"), "toolbar starts closed");
toggle.click();
assert.ok(toolbar.classList.contains("xlsx-tools-open"), "one click opens the tools");
assert.equal(toggle.getAttribute("aria-expanded"), "true");
assert.ok(toggle.classList.contains("active"), "the toggle shows its own state");
toggle.click();
assert.ok(!toolbar.classList.contains("xlsx-tools-open"), "a second click closes them");
assert.equal(toggle.getAttribute("aria-expanded"), "false");
});
await test("style.css hides menus/ribbon/find by default under 768px and reveals them on .xlsx-tools-open (#189)", () => {
const css = readFileSync(path.join(REPO_ROOT, "frontend", "style.css"), "utf8");
// The desktop default: the toggle itself is out of the way.
assert.match(css, /\.xlsx-tools-toggle\s*\{\s*display:\s*none/, "toggle hidden on desktop");
// Mobile block: collapsed by default… (CRLF-safe slice, from the #189
// comment up to the next top-level rule).
const from = css.indexOf("/* #189");
assert.ok(from > 0, "the #189 mobile block exists after the coarse-pointer rules");
const to = css.indexOf(".xlsx-context-item", from);
assert.ok(to > from, "the #189 block is followed by the next rule");
const block = css.slice(from, to);
for (const cls of [".xlsx-menubar", ".xlsx-cmdbar", ".xlsx-find-group"]) {
const hidden = new RegExp(cls.replace(".", "\\.") + "[^}]*display:\\s*none").test(block)
|| new RegExp(cls.replace(".", "\\.") + ",[\\s\\S]{0,120}?display:\\s*none").test(block);
assert.ok(hidden, `${cls} hidden by default on mobile`);
assert.ok(
block.includes(`.xlsx-toolbar.xlsx-tools-open ${cls}`),
`${cls} revealed by .xlsx-tools-open`,
);
}
// The formula bar always stays, and the open state can wrap it (find group).
assert.ok(block.includes(".xlsx-toolbar.xlsx-tools-open .xlsx-formula-bar"), "formula bar wraps when open");
// Touch: 44px targets, tabs on one scrollable row, 16px inputs (no iOS zoom).
assert.match(block, /min-width:\s*44px/, "44px touch targets");
assert.match(block, /\.xlsx-sheetbar \.xlsx-tabs\s*\{[^}]*overflow-x:\s*auto/, "tabs scroll horizontally");
assert.match(block, /font-size:\s*16px/, "inputs at 16px avoid the iOS focus zoom");
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);