feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
CI / lint (push) Successful in 2m36s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 2m39s
CI / e2e (push) Successful in 15m45s

L'editeur lisait les styles mais ne les ecrivait pas, exportait la feuille
entiere et laissait le dernier-ecrivain gagner entre processus.

- A8 : bouton Mise en forme (gras/italique/souligne, alignements, couleurs via
  selecteur natif, formats de nombre, fusion, volets figes, largeur/hauteur) et
  nouvelle route PUT .../xlsx/style (verrou, backup, swap atomique, garde de
  perte, If-Match) ; A9 : decision "pas de moteur de formule" annoncee dans
  l'UI ; A10 : undo/redo unifie, piles par fichier conservees au re-rendu
- A11 : export de la selection + Markdown/HTML/impression et recherche sur
  toutes les feuilles ; A12 : concurrence optimiste (If-Match -> 409 reparable,
  retry qui relit) ; A13 : cache des metadonnees par (chemin, mtime, taille)
- A14 : outils IA .xlsm/.csv + search_workbook, analyze_range,
  edit_xlsx_structure
- tests : test_xlsx_styles.py (17), test_spreadsheet_tools.py (34),
  TestOptimisticConcurrency/TestMetaCache, JSDOM xlsx-viewer 107/107
This commit is contained in:
2026-09-30 07:04:47 -04:00
parent 1bacfd69d9
commit c5c225a68e
32 changed files with 4615 additions and 302 deletions
+825 -15
View File
@@ -108,6 +108,9 @@ async function test(name, fn) {
// #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}`);
@@ -119,7 +122,7 @@ async function test(name, fn) {
}
}
const { renderXlsxViewer } = await import(
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.
@@ -140,7 +143,7 @@ const sheetHtml = (value) =>
// successive windows a lazy-loading sheet will request.
let apiQueue = [];
function mount({ lossy = [], sheet = {} } = {}) {
function mount({ lossy = [], sheet = {}, revision = null } = {}) {
const area = document.getElementById("content-area");
area.innerHTML = "";
renderXlsxViewer(area, {
@@ -149,6 +152,8 @@ function mount({ lossy = [], sheet = {} } = {}) {
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;
}
@@ -160,6 +165,19 @@ function mountTruncated({ total = 520, rows = 1 } = {}) {
});
}
/**
* 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}"]`);
@@ -334,7 +352,10 @@ await test("a 409 conflict shows a non-blocking retry banner and keeps the edits
nextResponse = () => ({ ok: true, status: 200, body: {} });
banner.querySelector(".xlsx-banner-retry").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
// #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");
});
@@ -479,7 +500,7 @@ await test("a failed window fetch keeps the footnote and shows an error toast",
await test("the formula bar starts empty and disabled", () => {
const area = mount();
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "—");
assert.equal(area.querySelector("#xlsx-active-cell").value, "");
assert.equal(area.querySelector("#xlsx-formula-input").disabled, true);
});
@@ -490,7 +511,7 @@ await test("focusing a cell shows its name and content in the bar", () => {
// JSDOM does not run the default focus behaviour on dispatchEvent, so go
// through the real API:
td.focus();
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1");
assert.equal(area.querySelector("#xlsx-formula-input").value, "100");
assert.equal(area.querySelector("#xlsx-formula-input").disabled, false);
});
@@ -517,13 +538,13 @@ await test("Tab and arrows move to the neighbour cell", () => {
const a1 = area.querySelector('td[data-cell="A1"]');
a1.focus();
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B1");
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").textContent, "B2");
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").textContent, "A2");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A2");
});
await test("Enter commits and Shift+Tab goes backwards", () => {
@@ -533,7 +554,7 @@ await test("Enter commits and Shift+Tab goes backwards", () => {
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
const b1 = area.querySelector('td[data-cell="B1"]');
b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
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 () => {
@@ -551,7 +572,7 @@ await test("a saved edit from the bar resets the dirty flag and orig value", asy
// ── A13 — sort / filter / find / CSV export ─────────────────────────────────
const mountGrid = () => {
const mountGrid = (revision = null) => {
const area = document.getElementById("content-area");
area.innerHTML = "";
const grid =
@@ -565,6 +586,7 @@ const mountGrid = () => {
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;
};
@@ -834,6 +856,22 @@ await test("a csv shows no tab strip and no + button", () => {
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
@@ -967,7 +1005,7 @@ await test("clicking a named range reveals its first cell in the grid", async ()
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").textContent, "A1", "the anchor cell becomes active");
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"));
});
@@ -1014,7 +1052,7 @@ await test("clear contents empties the selected cells (local edit)", () => {
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").textContent, "A1:A2");
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"]);
});
@@ -1022,7 +1060,7 @@ await test("clicking a column header selects the whole column", () => {
await test("clicking a row header selects the whole row", () => {
const area = mount();
area.querySelector("th.xlsx-rownum").click(); // row 1
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B1");
});
await test("dragging from a cell to another selects the range", () => {
@@ -1033,7 +1071,7 @@ await test("dragging from a cell to another selects the range", () => {
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").textContent, "A1:B2");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B2");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4);
});
@@ -1042,7 +1080,7 @@ await test("Shift+Arrow extends the selection from the anchor", () => {
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").textContent, "A1:B1");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B1");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 2);
});
@@ -1057,6 +1095,778 @@ await test("the context menu module exposes open/close helpers", async () => {
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", "");
assert.equal(cut.store.text, "A1\tB1\tC1\tD1\tE1\tF1");
clipboardEvent("paste", area, "A5", cut.store.text);
assert.deepEqual(cellTexts(area, ["A5", "B5"]), ["A1", "B1"]);
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "", "the cut source is cleared");
assert.equal(area.querySelector('td[data-cell="B1"]').textContent, "");
});
await test("cut then paste in place keeps the pasted values", () => {
const area = mountWide();
area.querySelector("th.xlsx-rownum").click(); // A1:F1
const cut = clipboardEvent("cut", area, "A1", "");
clipboardEvent("paste", area, "A1", cut.store.text);
assert.deepEqual(cellTexts(area, ["A1", "B1"]), ["A1", "B1"], "no self-erase");
});
await test("the context menu copies and pastes a block", () => {
const area = mountWide();
openMenuOn(area, "A1");
assert.ok(menuItem(FR["xlsx.copy"]), "Copier");
assert.ok(menuItem(FR["xlsx.cut"]), "Couper");
assert.ok(menuItem(FR["xlsx.paste"]), "Coller");
area.querySelector("th.xlsx-rownum").click();
openMenuOn(area, "A1");
menuItem(FR["xlsx.copy"]).click();
openMenuOn(area, "A4");
menuItem(FR["xlsx.paste"]).click();
assert.equal(area.querySelector('td[data-cell="A4"]').textContent, "A1");
assert.equal(area.querySelector('td[data-cell="B4"]').textContent, "B1");
});
await test("pasting with a stale clipboard is a no-op with a hint", async () => {
const area = mountWide();
const before = toastCount();
openMenuOn(area, "A1");
menuItem(FR["xlsx.paste"]).click();
await new Promise((r) => setTimeout(r, 5));
assert.equal(toastCount(), before + 1, "the user is told the clipboard is unusable");
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "A1", "nothing was written");
});
await test("a block that overflows the rendered grid is trimmed and signalled", () => {
const area = mountWide();
const before = toastCount();
clipboardEvent("paste", area, "E6", "1\t2\t3\n4\t5\t6");
assert.deepEqual(cellTexts(area, ["E6", "F6"]), ["1", "2"]);
assert.equal(area.querySelector('td[data-cell="F7"]'), null);
assert.equal(toastCount(), before + 1, "the overflow is announced");
});
await test("a pasted block is undoable cell by cell", () => {
const area = mountWide();
clipboardEvent("paste", area, "D5", "x\ty");
area.querySelector("#xlsx-undo-btn").click();
assert.equal(area.querySelector('td[data-cell="E5"]').textContent, "E5", "the last pasted cell is back");
area.querySelector("#xlsx-undo-btn").click();
assert.equal(area.querySelector('td[data-cell="D5"]').textContent, "D5");
});
await test("the cached-value shadow never leaks into a copied block", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
const html = '<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();
pressKey(td, "x"); // typing switches the cell to edit mode
assert.equal(td.dataset.editing, "1", "typing enters edit mode");
pressKey(td, "Delete");
assert.equal(td.textContent, "Banane", "the caret key was not hijacked");
});
await test("F2 enters the cell for text editing", () => {
const area = mountGrid();
const td = area.querySelector('td[data-cell="A1"]');
td.focus();
assert.equal(td.dataset.editing, "", "navigation mode after a plain focus");
pressKey(td, "F2");
assert.equal(td.dataset.editing, "1");
});
await test("Maj+Entrée inserts a line break inside the cell", () => {
const area = mountGrid();
const td = area.querySelector('td[data-cell="A1"]');
td.focus();
pressKey(td, "Enter", { shiftKey: true });
assert.ok(td.textContent.includes("\n"), `expected a newline in ${JSON.stringify(td.textContent)}`);
assert.equal(td.classList.contains("xlsx-dirty"), true);
});
await test("Ctrl+A selects the rendered sheet, Ctrl+Home/End walk its corners", () => {
const area = mountGrid();
const viewer = area.querySelector(".xlsx-viewer");
area.querySelector('td[data-cell="B2"]').focus();
pressKey(viewer, "a", { ctrlKey: true });
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B3");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 6);
pressKey(viewer, "Home", { ctrlKey: true });
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1");
pressKey(viewer, "End", { ctrlKey: true });
assert.equal(area.querySelector("#xlsx-active-cell").value, "B3");
});
await test("Home/End reach the row edges, Ctrl+flèches the data run", () => {
const area = mountWide();
const viewer = area.querySelector(".xlsx-viewer");
area.querySelector('td[data-cell="C3"]').focus();
pressKey(viewer, "End");
assert.equal(area.querySelector("#xlsx-active-cell").value, "F3");
pressKey(viewer, "Home");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A3");
pressKey(viewer, "ArrowDown", { ctrlKey: true });
assert.equal(area.querySelector("#xlsx-active-cell").value, "A6", "end of the filled column");
});
await test("PageDown/PageUp move by a screenful", () => {
const area = mountWide();
const viewer = area.querySelector(".xlsx-viewer");
area.querySelector('td[data-cell="A1"]').focus();
pressKey(viewer, "PageDown");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A6", "clamped to the last rendered row");
pressKey(viewer, "PageUp");
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1");
});
// ── A7 — zone Nom éditable & aide à la saisie ──────────────────────────────
await test("typing a reference in the Name Box activates that cell", () => {
const area = mountGrid();
const box = area.querySelector("#xlsx-active-cell");
box.value = "B3";
pressKey(box, "Enter");
assert.equal(box.value, "B3");
assert.equal(area.querySelector('td[data-cell="B3"]').classList.contains("xlsx-active"), true);
assert.equal(area.querySelector("#xlsx-formula-input").value, "2");
});
await test("the Name Box accepts a range and normalises it", () => {
const area = mountGrid();
const box = area.querySelector("#xlsx-active-cell");
box.value = "$a1:$b2";
pressKey(box, "Enter");
assert.equal(box.value, "A1:B2");
assert.deepEqual(selectedRefs(area), ["A1", "A2", "B1", "B2"]);
});
await test("an unknown reference is refused and the previous address restored", () => {
const area = mountGrid();
area.querySelector('td[data-cell="A1"]').focus();
const box = area.querySelector("#xlsx-active-cell");
const before = toastCount();
box.value = "XYZ";
pressKey(box, "Enter");
assert.equal(box.value, "A1");
assert.equal(toastCount(), before + 1, "the user is warned");
});
await test("a reference naming another sheet switches tab", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
const other = '<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" } });
apiQueue.push({
ok: true, status: 200,
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] },
});
openMenuOn(area, "A2");
menuItem(FR["xlsx.insert_row_above"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.actions, [{ op: "row_insert", sheet: "Fruits", at: 2, count: 1 }]);
assert.equal(area.querySelector("#xlsx-undo-btn").disabled, false, "the history survived the re-render");
area.querySelector("#xlsx-undo-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.match(calls[2].url, /\/xlsx\/structure/);
assert.deepEqual(calls[2].body.actions, [{ op: "row_delete", sheet: "Fruits", at: 2, count: 1 }], "the inverse batch");
});
await test("a destructive structure action is not undoable (#156-A10)", async () => {
const area = mountGrid();
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } });
apiQueue.push({
ok: true, status: 200,
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] },
});
openMenuOn(area, "A2");
menuItem(FR["xlsx.delete_rows"].replace("{n}", "1")).click();
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.actions, [{ op: "row_delete", sheet: "Fruits", at: 2, count: 1 }]);
assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true, "a delete cannot be replayed");
});
await test("the filter is recorded once and cleared by a single undo (#156-A10)", () => {
const area = mountGrid();
const input = area.querySelector("#xlsx-find-input");
const hidden = () => [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display === "none").length;
input.value = "ab";
input.dispatchEvent(new w.Event("input", { bubbles: true }));
input.value = "abr";
input.dispatchEvent(new w.Event("input", { bubbles: true }));
assert.equal(hidden(), 2, "two rows are hidden");
area.querySelector("#xlsx-undo-btn").click();
assert.equal(hidden(), 0, "one undo clears the whole typing session");
assert.equal(input.value, "", "the field follows");
});
// ── #156-A12 — concurrence optimiste (ETag / If-Match) ──────────────────────
await test("a save carries the read revision and refreshes it from the response (#156-A12)", async () => {
const area = mount({ revision: "rev-1" });
editCell(area, "A1", "42");
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 5));
assert.equal(calls[0].body.if_match, "rev-1", "the read token is sent back");
// A second save chains on the revision the first response returned.
editCell(area, "A2", "43");
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-3" } });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 5));
assert.equal(calls[1].body.if_match, "rev-2");
});
await test("a stale revision is repaired by re-reading before the retry (#156-A12)", async () => {
const area = mount({ revision: "rev-1" });
editCell(area, "A1", "42");
// 1) the save is refused as stale, 2) the retry re-reads the file, 3) the
// re-send succeeds — all queued up front so the re-read really answers.
apiQueue.push({
ok: false, status: 409,
body: { detail: "changed on disk", code: "conflict", details: { reason: "stale_revision", current_revision: "rev-9" } },
});
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
const banner = area.querySelector(".xlsx-banner-conflict");
assert.ok(banner, "the conflict banner is shown");
assert.equal(calls[0].body.if_match, "rev-1");
// The retry re-reads the file, then re-sends with the CURRENT revision.
apiQueue.push({
ok: true, status: 200,
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_revision: "rev-9", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [] },
});
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-10" } });
banner.querySelector(".xlsx-banner-retry").click();
await new Promise((r) => setTimeout(r, 10));
assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/);
assert.equal(calls[2].body.if_match, "rev-9", "the retry uses the revision just read");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
});
await test("a structural write carries the revision too (#156-A12)", async () => {
const area = mountGrid("rev-1");
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } });
apiQueue.push({
ok: true, status: 200,
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] },
});
openMenuOn(area, "A2");
menuItem(FR["xlsx.insert_row_above"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls[0].body.if_match, "rev-1");
});
// ── #156-A8 — mise en forme en écriture ────────────────────────────────────
/**
* Make `ref` the ACTIVE cell: the grid wires setActiveCell() on the cell's
* focus event, and td.focus() runs it in JSDOM (a synthetic click would not).
*/
const activateCell = (area, ref) => {
const td = area.querySelector(`td[data-cell="${ref}"]`);
td.focus();
return td;
};
const openFormatMenu = (area) => {
area.querySelector("#xlsx-format-btn").click();
return area.querySelector(".xlsx-format-menu");
};
const formatItem = (area, label) => {
const b = [...area.querySelectorAll(".xlsx-format-menu .xlsx-structure-item")]
.find((item) => item.textContent === label);
assert.ok(b, `format item "${label}" is present`);
return b;
};
/** Queue the PUT then the re-read the formatting flow performs. */
const queueStyleWrite = () => {
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } });
apiQueue.push({
ok: true, status: 200,
body: {
is_xlsx: true, vault: "V", path: "data.xlsx",
xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }],
xlsx_lossy_features: [],
},
});
};
await test("the formatting menu is offered on an editable workbook only (#156-A8)", () => {
const area = mountGrid();
assert.ok(area.querySelector("#xlsx-format-btn"), "the button is there");
assert.equal(area.querySelector("#xlsx-format-btn").getAttribute("title"), FR["xlsx.format_btn"]);
const csv = mountCsv();
assert.equal(csv.querySelector("#xlsx-format-btn"), null, "no formatting on a csv");
});
await test("bold applied to a selection goes to /xlsx/style and re-renders (#156-A8)", async () => {
const area = mountGrid();
dragSelect(area, "A1", "B2");
openFormatMenu(area);
queueStyleWrite();
formatItem(area, FR["xlsx.format_bold"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.match(calls[0].url, /\/xlsx\/style\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.ops, [
{ op: "cell", sheet: "Fruits", range: "A1:B2", style: { bold: true } },
]);
assert.equal(calls[0].body.force, false);
assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/, "the viewer re-reads the file");
});
await test("the bold toggle removes an existing bold (#156-A8)", async () => {
const area = mountGrid();
activateCell(area, "A1").style.fontWeight = "600";
openFormatMenu(area);
queueStyleWrite();
formatItem(area, FR["xlsx.format_bold"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.ops, [
{ op: "cell", sheet: "Fruits", range: "A1", style: { bold: false } },
]);
});
await test("alignment, underline and number formats are written (#156-A8)", async () => {
const area = mountGrid();
activateCell(area, "B2");
openFormatMenu(area);
queueStyleWrite();
formatItem(area, FR["xlsx.format_align_center"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.ops[0].style, { align: "center" });
// `calls` keeps accumulating (same test): the second write is calls[2].
const area2 = mountGrid();
activateCell(area2, "B2");
openFormatMenu(area2);
queueStyleWrite();
formatItem(area2, FR["xlsx.format_num_percent"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 4, "PUT + re-read for each formatting write");
assert.deepEqual(calls[2].body.ops[0].style, { number_format: "0.0%" });
assert.equal(calls[2].body.ops[0].range, "B2");
});
await test("merging needs a real range (#156-A8)", async () => {
const area = mountGrid();
activateCell(area, "A1");
const before = toastCount();
openFormatMenu(area);
formatItem(area, FR["xlsx.format_merge"]).click();
await new Promise((r) => setTimeout(r, 5));
assert.equal(calls.length, 0, "nothing is written for a single cell");
assert.equal(toastCount(), before + 1, "the user is told why");
});
await test("the merge action carries the selected range (#156-A8)", async () => {
const area = mountGrid();
dragSelect(area, "A1", "B1");
openFormatMenu(area);
queueStyleWrite();
formatItem(area, FR["xlsx.format_merge"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.ops, [{ op: "merge", sheet: "Fruits", range: "A1:B1" }]);
});
await test("freezing uses the active cell, unfreezing sends an empty one (#156-A8)", async () => {
const area = mountGrid();
activateCell(area, "B3");
openFormatMenu(area);
queueStyleWrite();
formatItem(area, FR["xlsx.format_freeze"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.ops, [{ op: "freeze", sheet: "Fruits", cell: "B3" }]);
const area2 = mountGrid();
openFormatMenu(area2);
queueStyleWrite();
formatItem(area2, FR["xlsx.format_unfreeze"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[2].body.ops, [{ op: "freeze", sheet: "Fruits", cell: "" }]);
});
await test("the column width prompt writes one op per selected column (#156-A8)", async () => {
const area = mountGrid();
dragSelect(area, "A1", "B2");
openFormatMenu(area);
formatItem(area, FR["xlsx.format_col_width"]).click();
await new Promise((r) => setTimeout(r, 5));
const input = lastDialog()?.querySelector('[data-dialog="input"]');
assert.ok(input, "a themed prompt asks for the width");
input.value = "30";
clickDialog("confirm");
queueStyleWrite();
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(calls[0].body.ops, [
{ op: "col_width", sheet: "Fruits", col: "A", width: 30 },
{ op: "col_width", sheet: "Fruits", col: "B", width: 30 },
]);
});
await test("a lossy refusal asks once then retries the style with force (#156-A8)", async () => {
const area = mount({ lossy: ["cached_values"] });
activateCell(area, "A1");
openFormatMenu(area);
apiQueue.push(lossyError); // 1) the style write is refused as lossy
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } }); // 2) forced retry
apiQueue.push({
ok: true, status: 200,
body: {
is_xlsx: true, vault: "V", path: "data.xlsx",
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
xlsx_lossy_features: [],
},
}); // 3) the re-read
formatItem(area, FR["xlsx.format_italic"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 1, "nothing is retried before the answer");
const dialog = lastDialog();
assert.ok(dialog, "a themed confirmation is shown");
// The 409 payload lists the features (slicers), not the mount's list.
assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent);
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
const retry = calls.find((c) => c.body && c.body.force === true);
assert.ok(retry, "the retry goes out with force");
assert.match(retry.url, /\/xlsx\/style\?path=data\.xlsx/);
});
// ── #156-A11 — sortie (CSV/MD/HTML/impression) + recherche classeur ────────
/**
* Click an export control and capture the file name and the payload it built.
* The real <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 export menu offers Markdown and writes a table (#156-A11)", () => {
const area = mountGrid();
area.querySelector("#xlsx-export-btn").click();
assert.equal(area.querySelector("#xlsx-export-menu").style.display, "block");
const { anchor, text } = captureExport(area, '[data-xlsx-export="md"]');
assert.equal(anchor.download, "Fruits.md");
assert.equal(text.split("\n")[0], "| Banane | 3 |");
assert.equal(text.split("\n")[1], "|---|---|");
assert.equal(area.querySelector("#xlsx-export-menu").style.display, "none", "the menu closes");
});
await test("the export menu writes a standalone HTML document (#156-A11)", () => {
const area = mountGrid();
area.querySelector("#xlsx-export-btn").click();
const { anchor, text } = captureExport(area, '[data-xlsx-export="html"]');
assert.equal(anchor.download, "Fruits.html");
assert.ok(text.startsWith("<!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-export-btn").click();
area.querySelector('[data-xlsx-export="print"]').click();
const frame = document.querySelector("body > iframe.xlsx-print-frame");
assert.ok(frame, "a print frame is created");
const doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document);
assert.ok(doc, "the frame carries its own document");
assert.ok(doc.body.textContent.includes("Banane"), doc.body.textContent);
assert.equal(document.body.classList.contains("xlsx-print"), false, "the print class is cleared");
// A second print does not pile frames up.
area.querySelector("#xlsx-export-btn").click();
area.querySelector('[data-xlsx-export="print"]').click();
assert.equal(document.querySelectorAll("body > iframe.xlsx-print-frame").length, 1);
});
await test("the find scans every sheet and steps across tabs (#156-A11)", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
const oneCell = (value) =>
'<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");
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);