feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": [],
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user