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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user