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
+57 -3
View File
@@ -241,17 +241,18 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
await openXlsx(page, LARGE);
// Un clic sur une cellule active la barre avec son nom et son contenu.
// (#156-A7 : la zone Nom est un input, donc on lit sa valeur.)
const cell = page.locator('#content-area td[data-cell="B2"]');
await cell.click();
await expect(page.locator('#xlsx-active-cell')).toHaveText('B2');
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B2');
const bar = page.locator('#xlsx-formula-input');
await expect(bar).toHaveValue(/Operation 1/);
// Les flèches déplacent la cellule active.
await cell.press('ArrowDown');
await expect(page.locator('#xlsx-active-cell')).toHaveText('B3');
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B3');
await page.locator('#content-area td[data-cell="B3"]').press('ArrowRight');
await expect(page.locator('#xlsx-active-cell')).toHaveText('C3');
await expect(page.locator('#xlsx-active-cell')).toHaveValue('C3');
// Éditer depuis la barre marque la cellule dirty, Échap annule.
await bar.fill('Operation 2 modifiee');
@@ -260,3 +261,56 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
});
});
// ── #156 P1 — presse-papiers de plage, clavier complet, zone Nom ────────────
test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)', () => {
test('coller une plage, la navigation clavier et la zone Nom (#156 A5/A6/A7)', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
const nameBox = page.locator('#xlsx-active-cell');
const cellOf = (ref) => page.locator(`#content-area td[data-cell="${ref}"]`);
const textOf = async (ref) => (await cellOf(ref).innerText()).trim();
// — A7 : la zone Nom est éditable et sert à « Atteindre ».
await nameBox.fill('B12');
await nameBox.press('Enter');
await expect(nameBox).toHaveValue('B12');
await expect(cellOf('B12')).toHaveClass(/xlsx-active/);
// — A6 : Ctrl+A sélectionne la feuille rendue, Ctrl+Home/End ses coins.
await cellOf('B12').click();
await page.keyboard.press('Control+a');
await expect(nameBox).toHaveValue('A1:C500');
await page.keyboard.press('Control+Home');
await expect(nameBox).toHaveValue('A1');
await page.keyboard.press('Control+End');
await expect(nameBox).toHaveValue('C500');
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
// utilise le presse-papiers interne de la grille : aucune permission
// navigateur n'est requise.
const src = { a1: await textOf('A1'), b1: await textOf('B1'), b2: await textOf('B2'), c10: await textOf('C10') };
await cellOf('A1').click();
await cellOf('B2').click({ modifiers: ['Shift'] });
await expect(nameBox).toHaveValue('A1:B2');
await cellOf('A1').click({ button: 'right' });
await page.locator('.xlsx-context-item', { hasText: 'Copier' }).click();
await cellOf('A10').click({ button: 'right' });
await page.locator('.xlsx-context-item', { hasText: 'Coller' }).click();
expect(await textOf('A10')).toBe(src.a1);
expect(await textOf('B10')).toBe(src.b1);
expect(await textOf('B11')).toBe(src.b2);
expect(await textOf('C10')).toBe(src.c10);
// — A6 : Suppr vide la sélection (et seulement elle).
await cellOf('A10').click();
await cellOf('B11').click({ modifiers: ['Shift'] });
await expect(nameBox).toHaveValue('A10:B11');
await page.keyboard.press('Delete');
expect(await textOf('A10')).toBe('');
expect(await textOf('B11')).toBe('');
expect(await textOf('C10')).toBe(src.c10);
});
});
+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);
+194
View File
@@ -223,3 +223,197 @@ class TestAppendXlsxRows:
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "rows": [],
})
# ── #156-A14 — formats étendus, recherche, analyse de plage, structure ──────
@pytest.fixture
def notes_csv(vault: Path) -> Path:
"""A `;`-separated CSV — the locale the viewer sniffs (BUG-098)."""
path = vault / "notes.csv"
path.write_text("Poste;Montant\nLoyer;900\nCourses;250\n", encoding="utf-8")
return path
class TestRegistryExtended:
def test_read_tools_include_search_and_analyze(self):
for name in ("search_workbook", "analyze_range"):
spec = get_tool(name)
assert spec is not None
assert spec.risk == ToolRisk.READ
def test_structure_tool_requires_confirmation(self, vault, workbook):
spec = get_tool("edit_xlsx_structure")
assert spec is not None
assert spec.risk == ToolRisk.WRITE
with pytest.raises(ToolConfirmationRequired):
call_tool("edit_xlsx_structure", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}],
})
class TestXlsmSupport:
def test_reads_and_edits_an_xlsm(self, vault):
path = vault / "macro.xlsm"
wb = openpyxl.Workbook()
wb.active.title = "Donnees"
wb.active.append(["A", "B"])
wb.active.append([1, 2])
wb.save(path)
out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "macro.xlsm"})
assert out.ok and out.data["sheets"][0]["name"] == "Donnees"
md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "macro.xlsm"})
assert "| A | B |" in md.data["markdown"]
edit = call_tool("update_xlsx_cells", _ctx(), {
"vault": "Vault", "path": "macro.xlsm", "sheet": "Donnees",
"cells": {"A2": "9"},
}, confirm=True)
assert edit.ok
wb2 = openpyxl.load_workbook(path)
assert wb2["Donnees"]["A2"].value == 9
wb2.close()
class TestCsvSupport:
def test_lists_reads_and_searches_a_csv(self, vault, notes_csv):
out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "notes.csv"})
assert out.ok
assert out.data["sheets"] == [
{"name": "notes", "total_rows": 3, "total_cols": 2, "truncated": False}
]
md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "notes.csv"})
assert "| Poste | Montant |" in md.data["markdown"]
found = call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "notes.csv", "query": "Loyer",
})
assert found.ok
assert found.data["total"] == 1
assert found.data["matches"][0] == {"sheet": "notes", "cell": "A2", "value": "Loyer"}
assert found.data["by_sheet"] == {"notes": 1}
def test_updates_a_csv_keeping_its_delimiter(self, vault, notes_csv):
out = call_tool("update_xlsx_cells", _ctx(), {
"vault": "Vault", "path": "notes.csv", "cells": {"B2": "1000"},
}, confirm=True)
assert out.ok
assert "Loyer;1000" in notes_csv.read_text(encoding="utf-8-sig")
def test_append_rows_refuses_a_csv(self, vault, notes_csv):
with pytest.raises(ToolError):
call_tool("append_xlsx_rows", _ctx(), {
"vault": "Vault", "path": "notes.csv",
"sheet": "notes", "rows": [["x"]],
}, confirm=True)
def test_structure_refuses_a_csv(self, vault, notes_csv):
with pytest.raises(ToolError):
call_tool("edit_xlsx_structure", _ctx(), {
"vault": "Vault", "path": "notes.csv",
"actions": [{"op": "sheet_add", "name": "X"}],
}, confirm=True)
def test_wrong_extension_is_still_refused(self, vault):
with pytest.raises(ToolError):
call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "note.md", "query": "a",
})
class TestSearchWorkbook:
def test_scans_every_sheet_with_counts(self, vault, workbook):
out = call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "query": "o",
})
assert out.ok
assert out.data["by_sheet"]["Budget"] > 0
assert out.data["by_sheet"]["Notes"] == 1 # « bonjour » : une seule cellule
assert out.data["truncated"] is False
assert out.data["total"] == sum(out.data["by_sheet"].values())
def test_case_sensitive_and_sheet_scope(self, vault, workbook):
loose = call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "query": "LOYER",
})
assert loose.data["total"] == 1
strict = call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "query": "LOYER",
"case_sensitive": True,
})
assert strict.data["total"] == 0
scoped = call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "query": "bonjour",
"sheet": "Budget",
})
assert scoped.data["total"] == 0
def test_empty_query_rejected(self, vault, workbook):
with pytest.raises(ToolError):
call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "query": " ",
})
class TestAnalyzeRange:
def test_aggregates_a_column(self, vault, workbook):
out = call_tool("analyze_range", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "range": "B2:B3",
})
assert out.ok
stats = out.data["numeric"]
assert stats["count"] == 2
assert stats["sum"] == 1150
assert stats["mean"] == 575
assert stats["min"] == 250 and stats["max"] == 900
def test_whole_sheet_and_fr_separators(self, vault):
path = vault / "prix.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["Article", "Prix"])
ws.append(["A", "1 234,5"])
ws.append(["B", 10])
wb.save(path)
out = call_tool("analyze_range", _ctx(), {"vault": "Vault", "path": "prix.xlsx"})
assert out.ok
assert out.data["numeric"]["count"] == 2
assert out.data["numeric"]["max"] == 1234.5
assert out.data["cells"] == 6 # 2 en-têtes + 4 valeurs
def test_bad_range_rejected(self, vault, workbook):
with pytest.raises(ToolError):
call_tool("analyze_range", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "range": "pas-une-plage",
})
class TestEditXlsxStructure:
def test_inserts_a_row_and_survives_a_reload(self, vault, workbook):
out = call_tool("edit_xlsx_structure", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}],
}, confirm=True)
assert out.ok and out.data["actions"] == 1
wb = openpyxl.load_workbook(workbook)
assert wb["Budget"]["A3"].value == "Loyer" # la ligne 2 a été poussée
wb.close()
def test_adds_a_sheet(self, vault, workbook):
out = call_tool("edit_xlsx_structure", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"actions": [{"op": "sheet_add", "name": "Trois"}],
}, confirm=True)
assert out.ok
wb = openpyxl.load_workbook(workbook)
assert "Trois" in wb.sheetnames
wb.close()
def test_empty_actions_rejected(self, vault, workbook):
with pytest.raises(ToolError):
call_tool("edit_xlsx_structure", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "actions": [],
})
+76 -1
View File
@@ -95,9 +95,32 @@ class TestXlsmEditable:
with zipfile.ZipFile(_vault_file(client, xlsm_book)) as zf:
assert "xl/vbaProject.bin" in zf.namelist()
def test_lossy_gate_is_skipped_for_xlsm(self, formats_vault, xlsm_book):
def test_sheet_window_is_served_for_xlsm(self, client, xlsm_book):
"""BUG-097 — a .xlsm is editable, so its lazy row windows must work."""
resp = client.get(
f"/api/file/{VAULT}/xlsx/sheet",
params={"path": xlsm_book, "sheet": "Data", "offset": 0, "limit": 100},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["sheet"] == "Data"
assert 'data-cell="A2"' in body["html"]
assert body["has_more"] is False
def test_sheet_window_still_refuses_other_formats(self, client, formats_vault):
(formats_vault / "plain.csv").write_text("a,b\n", encoding="utf-8")
resp = client.get(
f"/api/file/{VAULT}/xlsx/sheet",
params={"path": "plain.csv", "sheet": "Feuille1"},
)
assert resp.status_code == 415
def test_lossy_gate_is_skipped_for_xlsm(self, client, formats_vault, xlsm_book):
from backend.services.mutations import edit_xlsx_cells
# `client` is required: the service resolves the vault through the app
# registry (the test used to pass only thanks to state leaked by an
# earlier test in this class).
# No force flag: the save must succeed despite the vbaProject part
# (it would raise xlsx_lossy_content on a plain .xlsx).
result = edit_xlsx_cells(VAULT, xlsm_book, "Data", {"A2": "Alan"})
@@ -149,6 +172,22 @@ class TestLegacyReadOnly:
assert sheet["rows"] == 0
class TestCsvDelimiter:
"""BUG-098 — the delimiter is detected, not assumed to be a comma."""
def test_sniffing_handles_the_common_separators(self):
from backend.xlsx_reader import sniff_csv_delimiter
assert sniff_csv_delimiter("a;b\n1;2\n") == ";"
assert sniff_csv_delimiter("a,b\n1,2\n") == ","
assert sniff_csv_delimiter("a\tb\n1\t2\n") == "\t"
def test_single_column_falls_back_to_a_comma(self):
from backend.xlsx_reader import sniff_csv_delimiter
assert sniff_csv_delimiter("juste\nune\ncolonne\n") == ","
class TestCsvEditable:
def test_read_renders_the_xlsx_shaped_table(self, client, formats_vault):
(formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8")
@@ -197,6 +236,42 @@ class TestCsvEditable:
text = _vault_file(client, "quotes.csv").read_text(encoding="utf-8")
assert '"Martin, Pierre"' in text
def test_semicolon_csv_renders_multiple_columns(self, client, formats_vault):
"""BUG-098 — a French (semicolon) CSV is no longer a single column."""
(formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8")
html = client.get(
f"/api/file/{VAULT}", params={"path": "fr.csv"}
).json()["html"]
assert 'data-cell="B1"' in html and 'data-cell="B2"' in html
assert "Paris" in html
def test_semicolon_is_preserved_when_saving(self, client, formats_vault):
"""BUG-098 — the write-back keeps the file's own delimiter."""
(formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8")
resp = client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "fr.csv"},
json={"cells": {"B2": "Lyon"}},
)
assert resp.status_code == 200
text = _vault_file(client, "fr.csv").read_text(encoding="utf-8")
assert "Nom;Ville" in text
assert "Ada;Lyon" in text
assert "," not in text # the source delimiter is not switched to a comma
def test_quoted_semicolon_survives_a_round_trip(self, client, formats_vault):
(formats_vault / "quote-fr.csv").write_text(
'Nom;Note\n"Dupont; Jean";ok\n', encoding="utf-8"
)
client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "quote-fr.csv"},
json={"cells": {"B2": "modifié"}},
)
text = _vault_file(client, "quote-fr.csv").read_text(encoding="utf-8")
assert '"Dupont; Jean"' in text
assert ";modifié" in text
def test_bad_reference_is_refused(self, client, formats_vault):
(formats_vault / "liste.csv").write_text("a\n", encoding="utf-8")
resp = client.put(
+168
View File
@@ -35,6 +35,20 @@ def styled_book(test_vault_dir: str) -> str:
return str(path)
@pytest.fixture
def plain_book(test_vault_dir: str) -> str:
"""An unstyled workbook — the write side starts from a blank slate."""
path = Path(test_vault_dir) / "plain.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Data"
ws.append(["Nom", "Valeur"])
ws.append(["a", 1])
ws.append(["b", 2])
wb.save(path)
return str(path)
class TestStyleReading:
def test_bold_italic_and_font_color(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
@@ -82,6 +96,160 @@ class TestStyleReading:
assert read_sheet_styles(bad, "Data") == {}
class TestStyleWriting:
"""#156-A8 — the viewer can now WRITE formatting, not just read it.
One locked, atomic rewrite through ``PUT …/xlsx/style``: styles (bold,
italic, underline, colours, alignment, number format), merges, column
widths, row heights and frozen panes.
"""
def _style(self, client, path, ops, **extra):
return client.put(
f"/api/file/{VAULT}/xlsx/style",
params={"path": path},
json={"ops": ops, **extra},
)
def test_styles_survive_a_reload(self, client, plain_book):
resp = self._style(client, "plain.xlsx", [
{
"op": "cell", "sheet": "Data", "range": "A1:B1",
"style": {
"bold": True, "italic": True, "underline": True,
"font_color": "#cc0000", "fill_color": "#ffe08a",
"align": "center", "number_format": "0.00",
},
},
])
assert resp.status_code == 200
assert resp.json()["revision"]
wb = openpyxl.load_workbook(plain_book)
ws = wb["Data"]
assert ws["A1"].font.bold is True and ws["A1"].font.italic is True
assert ws["A1"].font.underline == "single"
assert ws["A1"].font.color.rgb == "FFCC0000"
assert ws["A1"].fill.start_color.rgb == "FFFFE08A"
assert ws["A1"].alignment.horizontal == "center"
assert ws["B1"].number_format == "0.00"
assert ws["A1"].value == "Nom", "the value is untouched"
assert not ws["A2"].font.bold, "a neighbour outside the range is untouched"
wb.close()
def test_the_written_style_is_read_back_by_the_viewer(self, client, plain_book):
self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "fill_color": "#ffe08a"}},
])
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
assert resp.status_code == 200
sheet = resp.json()["xlsx_sheets"][0]
style = sheet["styles"]["A1"]
assert "font-weight:600" in style and "background:#ffe08a" in style
def test_merges_sizes_and_freeze(self, client, plain_book):
resp = self._style(client, "plain.xlsx", [
{"op": "merge", "sheet": "Data", "range": "A1:B1"},
{"op": "col_width", "sheet": "Data", "col": "A", "width": 24},
{"op": "row_height", "sheet": "Data", "row": 2, "height": 30},
{"op": "freeze", "sheet": "Data", "cell": "A2"},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
ws = wb["Data"]
assert [str(r) for r in ws.merged_cells.ranges] == ["A1:B1"]
assert ws.column_dimensions["A"].width == 24
assert ws.row_dimensions[2].height == 30
assert str(ws.freeze_panes) == "A2"
wb.close()
resp = self._style(client, "plain.xlsx", [
{"op": "unmerge", "sheet": "Data", "range": "A1:B1"},
{"op": "freeze", "sheet": "Data", "cell": ""},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
ws = wb["Data"]
assert not list(ws.merged_cells.ranges)
assert ws.freeze_panes is None
wb.close()
def test_a_style_can_be_cleared(self, client, plain_book):
self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "font_color": "#cc0000", "fill_color": "#ffe08a"}},
])
resp = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": False, "font_color": "", "fill_color": "", "number_format": "General"}},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
ws = wb["Data"]
assert ws["A1"].font.bold in (None, False)
assert ws["A1"].font.color is None
assert ws["A1"].fill.fill_type is None
assert ws["A1"].number_format == "General"
wb.close()
def test_invalid_inputs_are_refused(self, client, plain_book):
bad_style = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"blink": True}},
])
assert bad_style.status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {}},
]).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"fill_color": "rouge"}},
]).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "pas-une-plage", "style": {"bold": True}},
]).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Nope", "range": "A1", "style": {"bold": True}},
]).status_code == 400
assert self._style(client, "plain.xlsx", [{"op": "spin", "sheet": "Data"}]).status_code == 400
assert self._style(client, "plain.xlsx", []).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "col_width", "sheet": "Data", "col": "A", "width": 900},
]).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"align": "diagonal"}},
]).status_code == 400
def test_a_csv_is_refused(self, client, test_vault_dir):
(Path(test_vault_dir) / "style.csv").write_text("a,b\n1,2\n", encoding="utf-8")
resp = self._style(client, "style.csv", [
{"op": "cell", "sheet": "x", "range": "A1", "style": {"bold": True}},
])
assert resp.status_code == 400
def test_a_stale_revision_is_refused_and_the_fresh_one_accepted(self, client, plain_book):
first = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}},
])
assert first.status_code == 200
stale = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}},
], if_match="deadbeef-1")
assert stale.status_code == 409
assert stale.json()["details"]["reason"] == "stale_revision"
fresh = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}},
], if_match=first.json()["revision"])
assert fresh.status_code == 200
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A3", "style": {"bold": True}},
], if_match=42).status_code == 400
def test_a_backup_is_written_before_the_rewrite(self, client, plain_book):
from backend.services.backups import get_backup_dir
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}},
]).status_code == 200
backups = list(get_backup_dir(VAULT, "plain.xlsx").glob("plain.xlsx.*.bak"))
assert backups, "the rewrite is backed up first"
class TestEndpointWiring:
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
+219
View File
@@ -474,6 +474,64 @@ class TestXlsxLossyGuard:
assert inspect_workbook(bad) == []
# ── BUG-099 — the cached-value probe must never fail silently ──────────────
def _probe_zip(tmp_path: Path, sheets: list[bytes]) -> Path:
"""Build a minimal archive holding the given worksheet XML parts."""
path = tmp_path / "probe.xlsx"
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
for i, blob in enumerate(sheets, start=1):
zf.writestr(f"xl/worksheets/sheet{i}.xml", blob)
return path
class TestXlsxCachedValueProbe:
"""BUG-099 — an exhausted probe is reported, never mistaken for clean."""
def test_exhausted_budget_is_reported_as_unverified(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from backend import xlsx_reader
# Budget smaller than the only sheet: the scan cannot reach its end.
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 64)
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 64)
book = _probe_zip(tmp_path, [b"<x/>" + b"filler" * 1024])
# Contre-preuve: the old code returned [] here — an interrupted scan was
# indistinguishable from a completed one, so the write guard stayed
# silent and the cached values could be dropped without warning.
assert xlsx_reader.inspect_workbook(book) == ["cached_values_unverified"]
def test_a_huge_first_sheet_does_not_starve_the_next_one(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from backend import xlsx_reader
# Per-sheet cap (1 KiB) well under the total allowance (4 KiB): a single
# shared budget would spend everything on sheet 1 and never look at
# sheet 2, where the only cached formula lives.
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 1024)
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 4096)
cached = b'<sheetData><c r="A1"><f>1+1</f><v>2</v></c></sheetData>'
book = _probe_zip(tmp_path, [b"filler" * 2048, cached])
assert "cached_values" in xlsx_reader.inspect_workbook(book)
def test_a_completed_clean_scan_reports_nothing(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from backend import xlsx_reader
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 4096)
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 8192)
book = _probe_zip(tmp_path, [b"<x/>", b"<x/>"])
# No cached value and no budget issue: no false positive either.
assert xlsx_reader.inspect_workbook(book) == []
# ── #153 A2 — atomic write ───────────────────────────────────────────────
@@ -741,3 +799,164 @@ class TestXlsxFormulaGuard:
ws = openpyxl.load_workbook(xlsx_file)["Budget"]
assert ws["B3"].value == 42 and isinstance(ws["B3"].value, int)
assert ws["B4"].value == -3.5
class TestMetaCache:
"""#156-A13 — ``read_workbook_meta`` is cached on (path, mtime_ns, size).
A truncated sheet is fetched window by window: without the cache every
window paid a full normal-mode workbook load just for the style / merge /
freeze maps of one sheet.
"""
def test_second_call_is_served_from_the_cache(self, xlsx_file, monkeypatch):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
real_load = xlsx_reader.load_workbook
loads = []
def counting(*args, **kwargs):
loads.append(1)
return real_load(*args, **kwargs)
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
first = xlsx_reader.read_workbook_meta(path)
assert len(loads) == 1, "la première lecture charge le classeur"
second = xlsx_reader.read_workbook_meta(path)
assert len(loads) == 1, "la seconde lecture ne recharge pas le classeur"
assert second is first, "la même carte est servie (lecture seule côté appelant)"
assert "Budget" in first
def test_invalidation_forces_a_reload(self, xlsx_file, monkeypatch):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
real_load = xlsx_reader.load_workbook
loads = []
def counting(*args, **kwargs):
loads.append(1)
return real_load(*args, **kwargs)
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
xlsx_reader.read_workbook_meta(path)
xlsx_reader.invalidate_workbook_meta(path)
xlsx_reader.read_workbook_meta(path)
assert len(loads) == 2, "l'invalidation explicite force un rechargement"
def test_rewriting_the_file_invalidates_the_cache(self, xlsx_file):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == []
wb = openpyxl.load_workbook(xlsx_file)
wb["Budget"].merge_cells("A1:B1")
wb.save(xlsx_file)
wb.close()
# (mtime_ns, size) changed: the cached maps cannot be served any more.
assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == ["A1:B1"]
def test_two_windows_share_one_metadata_load(self, xlsx_file, monkeypatch):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
real_load = xlsx_reader.load_workbook
normal = []
def counting(*args, **kwargs):
if not kwargs.get("read_only"):
normal.append(1)
return real_load(*args, **kwargs)
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
assert xlsx_reader.read_sheet_window(path, "Budget", 0, 1) is not None
assert xlsx_reader.read_sheet_window(path, "Budget", 1, 1) is not None
assert len(normal) == 1, "les deux fenêtres partagent une seule lecture de métadonnées"
class TestOptimisticConcurrency:
"""#156-A12 — the read exposes a revision, a write may require it.
A stale ``if_match`` (the file changed since it was read: Excel, the
watcher, another worker) is refused with 409 ``conflict`` instead of
silently overwriting the other writer.
"""
def _save(self, client, body, path="budget.xlsx"):
return client.put(
f"/api/file/{VAULT}/xlsx/save", params={"path": path}, json=body
)
def _revision(self, client, path="budget.xlsx"):
resp = client.get(f"/api/file/{VAULT}", params={"path": path})
assert resp.status_code == 200
return resp.json()["xlsx_revision"]
def test_the_read_exposes_a_revision_and_the_write_refreshes_it(self, client, xlsx_file):
rev = self._revision(client)
assert isinstance(rev, str) and rev
resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev})
assert resp.status_code == 200
assert resp.json()["revision"] and resp.json()["revision"] != rev
def test_a_stale_if_match_is_refused_and_writes_nothing(self, client, xlsx_file):
rev = self._revision(client)
assert self._save(
client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev}
).status_code == 200
# The token is stale now: the client must re-read before writing again.
resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "deux"}, "if_match": rev})
assert resp.status_code == 409
assert resp.json()["code"] == "conflict"
assert resp.json()["details"]["reason"] == "stale_revision"
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value is None
def test_omitting_if_match_keeps_last_writer_wins(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "sans-jeton"}})
assert resp.status_code == 200
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value == "sans-jeton"
def test_an_invalid_if_match_is_rejected(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "x"}, "if_match": 42})
assert resp.status_code == 400
def test_the_structure_endpoint_honours_if_match(self, client, xlsx_file):
rev = self._revision(client)
actions = [{"op": "row_insert", "sheet": "Budget", "at": 1}]
ok = client.put(
f"/api/file/{VAULT}/xlsx/structure",
params={"path": "budget.xlsx"},
json={"actions": actions, "if_match": rev},
)
assert ok.status_code == 200
stale = client.put(
f"/api/file/{VAULT}/xlsx/structure",
params={"path": "budget.xlsx"},
json={"actions": actions, "if_match": rev},
)
assert stale.status_code == 409
assert stale.json()["code"] == "conflict"
def test_the_csv_endpoint_honours_if_match(self, client, test_vault_dir):
csv_path = Path(test_vault_dir) / "notes.csv"
csv_path.write_text("a,b\n1,2\n", encoding="utf-8")
rev = self._revision(client, "notes.csv")
ok = client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "notes.csv"},
json={"cells": {"A1": "x"}, "if_match": rev},
)
assert ok.status_code == 200
assert ok.json()["revision"] != rev
stale = client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "notes.csv"},
json={"cells": {"A1": "y"}, "if_match": rev},
)
assert stale.status_code == 409
assert csv_path.read_text(encoding="utf-8").startswith("x")