1063 lines
48 KiB
JavaScript
1063 lines
48 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ObsiGate — JSDOM integration tests for the Excel viewer (ROADMAP #153 P0).
|
|
*
|
|
* Loads the real viewer.js module and drives renderXlsxViewer():
|
|
* - A1 : `xlsx_lossy_features` renders a warning banner; a save on such a
|
|
* workbook gets 409 `xlsx_lossy_content`, asks for confirmation and
|
|
* retries with `force: true` (or gives up when refused);
|
|
* - A4 : the f(x) toggle flips `allow_formula` in the save payload.
|
|
* - A8 : a sheet bigger than the render caps shows the truncation notice.
|
|
* - A9bis : the tail of a truncated sheet is fetched window by window from
|
|
* GET …/xlsx/sheet (scroll sentinel + click), and the appended rows are
|
|
* editable like the initial ones.
|
|
* - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing
|
|
* from the bar marks the cell dirty; Escape reverts.
|
|
* - A13 : header click sorts the rendered rows, the filter hides rows, the
|
|
* find highlights matches, CSV export downloads the visible sheet.
|
|
* - A14 : the structure menu sends one PUT …/xlsx/structure with the action,
|
|
* then re-renders from the server; destructive actions confirm first.
|
|
*
|
|
* Usage: node tests/frontend/xlsx-viewer.test.mjs
|
|
*/
|
|
|
|
import { strict as assert } from "node:assert";
|
|
import { JSDOM } from "jsdom";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
|
|
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
|
|
const dom = new JSDOM(
|
|
`<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<div id="content-area"></div>
|
|
</body>
|
|
</html>`,
|
|
{ url: "http://localhost/", pretendToBeVisual: true }
|
|
);
|
|
|
|
const w = dom.window;
|
|
globalThis.window = w;
|
|
globalThis.document = w.document;
|
|
globalThis.DOMParser = w.DOMParser;
|
|
globalThis.HTMLElement = w.HTMLElement;
|
|
globalThis.Element = w.Element;
|
|
globalThis.Node = w.Node;
|
|
globalThis.Event = w.Event;
|
|
globalThis.CustomEvent = w.CustomEvent;
|
|
globalThis.MouseEvent = w.MouseEvent;
|
|
globalThis.localStorage = w.localStorage;
|
|
globalThis.sessionStorage = w.sessionStorage;
|
|
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
|
|
Object.defineProperty(globalThis, "navigator", {
|
|
value: w.navigator,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
|
|
// ── fetch / confirm doubles ─────────────────────────────────────────────────
|
|
let calls = [];
|
|
let nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } });
|
|
let confirmAnswer = true;
|
|
let confirmCalls = 0;
|
|
// Every confirm() prompt is captured so the tests can assert on its text.
|
|
let confirmPrompts = [];
|
|
|
|
const FR = JSON.parse(
|
|
readFileSync(path.join(REPO_ROOT, "frontend", "locales", "fr.json"), "utf8")
|
|
);
|
|
|
|
globalThis.fetch = async (url, opts = {}) => {
|
|
// The i18n bootstrap fetches the locale files: serve the real FR one so the
|
|
// assertions run on the shipped strings, not on raw keys.
|
|
if (url.includes("/static/locales/")) {
|
|
return { ok: true, status: 200, json: async () => FR };
|
|
}
|
|
calls.push({ url, body: opts.body ? JSON.parse(opts.body) : null });
|
|
const res = nextResponse(url, opts);
|
|
return {
|
|
ok: res.ok,
|
|
status: res.status,
|
|
json: async () => res.body,
|
|
};
|
|
};
|
|
globalThis.confirm = (msg) => { confirmCalls++; confirmPrompts.push(msg); return confirmAnswer; };
|
|
w.confirm = globalThis.confirm;
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
let testCount = 0;
|
|
let passCount = 0;
|
|
|
|
async function test(name, fn) {
|
|
testCount++;
|
|
calls = [];
|
|
confirmCalls = 0;
|
|
confirmPrompts = [];
|
|
confirmAnswer = true;
|
|
apiQueue = [];
|
|
nextResponse = () => {
|
|
if (apiQueue.length) return apiQueue.shift();
|
|
return { ok: true, status: 200, body: { status: "ok" } };
|
|
};
|
|
// #154-A2 — themed dialogs live in document.body; clear any left behind by a
|
|
// previous test so the helpers always reach the current one.
|
|
document.querySelectorAll(".obsigate-modal-overlay").forEach((n) => n.remove());
|
|
try {
|
|
await fn();
|
|
console.log(` ✓ ${name}`);
|
|
passCount++;
|
|
} catch (e) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` ${e.message}`);
|
|
if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`);
|
|
}
|
|
}
|
|
|
|
const { renderXlsxViewer } = await import(
|
|
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "viewer.js")).href
|
|
);
|
|
// Load the FR catalog so t() resolves the real strings.
|
|
const { initI18n } = await import(
|
|
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href
|
|
);
|
|
await initI18n();
|
|
|
|
const sheetHtml = (value) =>
|
|
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
|
'<thead><tr><th class="xlsx-corner"></th><th>A</th><th>B</th></tr></thead><tbody>' +
|
|
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td><td data-cell="B1">B1</td></tr>` +
|
|
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">A2</td><td data-cell="B2">B2</td></tr>' +
|
|
"</tbody></table></div>";
|
|
|
|
// The JSDOM fetch double serves locale files; everything else is a recorded
|
|
// API call answered by `nextResponse`. `apiQueue` lets a test script the
|
|
// successive windows a lazy-loading sheet will request.
|
|
let apiQueue = [];
|
|
|
|
function mount({ lossy = [], sheet = {} } = {}) {
|
|
const area = document.getElementById("content-area");
|
|
area.innerHTML = "";
|
|
renderXlsxViewer(area, {
|
|
vault: "V",
|
|
path: "data.xlsx",
|
|
is_xlsx: true,
|
|
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }],
|
|
xlsx_lossy_features: lossy,
|
|
});
|
|
return area;
|
|
}
|
|
|
|
/** Mount a sheet flagged truncated so wireLazyRows() arms the footnote. */
|
|
function mountTruncated({ total = 520, rows = 1 } = {}) {
|
|
return mount({
|
|
sheet: { rows, cols: 3, total_rows: total, total_cols: 3, max_rows: 500, max_cols: 40, truncated: total > 500 },
|
|
});
|
|
}
|
|
|
|
/** Mark a cell dirty the way a user edit would. */
|
|
function editCell(area, ref, text) {
|
|
const td = area.querySelector(`td[data-cell="${ref}"]`);
|
|
td.textContent = text;
|
|
td.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
return td;
|
|
}
|
|
|
|
/** #154-A2 — the themed dialog is the last overlay in document.body. */
|
|
const lastDialog = () => {
|
|
const overlays = document.querySelectorAll(".obsigate-modal-overlay");
|
|
return overlays[overlays.length - 1] || null;
|
|
};
|
|
const clickDialog = (which) => {
|
|
const btn = lastDialog()?.querySelector(`[data-dialog="${which}"]`);
|
|
assert.ok(btn, `dialog button "${which}" is present`);
|
|
btn.click();
|
|
};
|
|
|
|
/** #155 — open the grid context menu on a cell (right-click). */
|
|
const openMenuOn = (area, ref) => {
|
|
const td = area.querySelector(`td[data-cell="${ref}"]`);
|
|
assert.ok(td, `cell ${ref} exists`);
|
|
td.dispatchEvent(new w.MouseEvent("contextmenu", { bubbles: true, clientX: 12, clientY: 12 }));
|
|
return document.querySelector(".xlsx-context-menu");
|
|
};
|
|
const menuItem = (label) =>
|
|
[...document.querySelectorAll(".xlsx-context-item")].find((b) => b.textContent === label);
|
|
|
|
const lossyError = {
|
|
ok: false,
|
|
status: 409,
|
|
body: {
|
|
detail: "Saving this workbook would drop features…",
|
|
code: "xlsx_lossy_content",
|
|
details: { features: ["slicers"] },
|
|
},
|
|
};
|
|
|
|
console.log("\n── xlsx viewer JSDOM integration tests (#153 P0) ──\n");
|
|
|
|
// ── A1 — warning banner ─────────────────────────────────────────────────────
|
|
|
|
await test("no banner when the workbook has nothing at risk", () => {
|
|
const area = mount();
|
|
assert.equal(area.querySelector(".xlsx-warning"), null);
|
|
});
|
|
|
|
await test("banner lists every lossy feature reported by the backend", () => {
|
|
const area = mount({ lossy: ["cached_values", "slicers"] });
|
|
const banner = area.querySelector(".xlsx-warning");
|
|
assert.ok(banner, "banner absent");
|
|
const tags = [...banner.querySelectorAll(".xlsx-warning-tag")].map((n) => n.textContent);
|
|
assert.equal(tags.length, 2);
|
|
assert.ok(tags.includes(FR["xlsx.feature_cached_values"]), tags.join("|"));
|
|
assert.ok(tags.includes(FR["xlsx.feature_slicers"]), tags.join("|"));
|
|
assert.ok(banner.textContent.includes(FR["xlsx.lossy_title"]));
|
|
assert.ok(banner.textContent.includes(FR["xlsx.lossy_hint"]));
|
|
});
|
|
|
|
await test("formula toggle is present and starts unpressed", () => {
|
|
const area = mount();
|
|
const btn = area.querySelector("#xlsx-formula-btn");
|
|
assert.ok(btn);
|
|
assert.equal(btn.getAttribute("aria-pressed"), "false");
|
|
assert.equal(btn.getAttribute("title"), FR["xlsx.formula_toggle_title"]);
|
|
});
|
|
|
|
// ── Save payload ────────────────────────────────────────────────────────────
|
|
|
|
await test("save sends one PUT per dirty sheet with the cell map", async () => {
|
|
const area = mount();
|
|
editCell(area, "A1", "250");
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/save\?path=data\.xlsx/);
|
|
assert.deepEqual(calls[0].body.cells, { A1: "250" });
|
|
assert.equal(calls[0].body.sheet, "Feuille1");
|
|
assert.equal(calls[0].body.force, false);
|
|
assert.equal(calls[0].body.allow_formula, false);
|
|
});
|
|
|
|
await test("save button stays disabled when nothing is dirty", async () => {
|
|
const area = mount();
|
|
const btn = area.querySelector("#xlsx-save-btn");
|
|
assert.equal(btn.disabled, true);
|
|
btn.click();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
assert.equal(calls.length, 0);
|
|
});
|
|
|
|
// ── A4 — formula toggle ─────────────────────────────────────────────────────
|
|
|
|
await test("f(x) toggle flips allow_formula on the next save", async () => {
|
|
const area = mount();
|
|
area.querySelector("#xlsx-formula-btn").click();
|
|
assert.equal(area.querySelector("#xlsx-formula-btn").getAttribute("aria-pressed"), "true");
|
|
editCell(area, "A1", "=B1*2");
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
assert.equal(calls[0].body.allow_formula, true);
|
|
});
|
|
|
|
// ── A1 — 409 confirmation & force retry ─────────────────────────────────────
|
|
|
|
await test("409 xlsx_lossy_content asks once then retries with force", async () => {
|
|
const area = mount({ lossy: ["slicers"] });
|
|
editCell(area, "A1", "250");
|
|
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
// #154-A2 — a themed dialog replaces window.confirm(), and no retry happens
|
|
// before the user answers.
|
|
const dialog = lastDialog();
|
|
assert.ok(dialog, "a themed confirmation is shown");
|
|
assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent);
|
|
assert.equal(calls.length, 1, "nothing is retried before the answer");
|
|
clickDialog("confirm");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.equal(calls.length, 2);
|
|
assert.equal(calls[0].body.force, false);
|
|
assert.equal(calls[1].body.force, true);
|
|
// Save succeeded → cells are no longer dirty.
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
|
});
|
|
|
|
await test("confirming once is enough for the following saves", async () => {
|
|
const area = mount({ lossy: ["cached_values"] });
|
|
editCell(area, "A1", "1");
|
|
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
clickDialog("confirm");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
editCell(area, "A1", "2");
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.equal(calls.length, 3);
|
|
assert.equal(calls[2].body.force, true);
|
|
// Wait out the dialog close animation, then confirm none is left.
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
assert.equal(document.querySelectorAll(".obsigate-modal-overlay").length, 0, "the user is not asked twice");
|
|
});
|
|
|
|
await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => {
|
|
const area = mount({ lossy: ["slicers"] });
|
|
editCell(area, "A1", "250");
|
|
nextResponse = () => lossyError;
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
clickDialog("cancel");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.equal(calls.length, 1, "no retry after a refusal");
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
|
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
|
});
|
|
|
|
await test("a 409 conflict shows a non-blocking retry banner and keeps the edits", async () => {
|
|
const area = mount();
|
|
editCell(area, "A1", "250");
|
|
nextResponse = () => ({ ok: false, status: 409, body: { detail: "busy", code: "conflict" } });
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const banner = area.querySelector(".xlsx-banner-conflict");
|
|
assert.ok(banner, "a conflict banner is shown");
|
|
assert.ok(banner.textContent.includes(FR["xlsx.conflict_msg"]), banner.textContent);
|
|
// The edit is preserved and the save button is usable again.
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
|
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
|
// The retry re-runs the save.
|
|
nextResponse = () => ({ ok: true, status: 200, body: {} });
|
|
banner.querySelector(".xlsx-banner-retry").click();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.equal(calls.length, 2);
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
|
assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry");
|
|
});
|
|
|
|
await test("a non-409 failure is not retried", async () => {
|
|
const area = mount();
|
|
editCell(area, "A1", "250");
|
|
nextResponse = () => ({ ok: false, status: 500, body: { detail: "boom" } });
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(confirmCalls, 0);
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
|
});
|
|
|
|
// ── A8 — truncation notice ───────────────────────────────────────────────────
|
|
|
|
await test("no notice when the sheet fits within the render caps", () => {
|
|
const area = mount({
|
|
sheet: { rows: 500, cols: 40, total_rows: 500, total_cols: 40, max_rows: 500, max_cols: 40, truncated: false },
|
|
});
|
|
assert.equal(area.querySelector(".xlsx-truncated"), null);
|
|
});
|
|
|
|
await test("notice states the cap and the real size of a truncated sheet", () => {
|
|
const area = mount({
|
|
sheet: { rows: 500, cols: 12, total_rows: 1200, total_cols: 12, max_rows: 500, max_cols: 40, truncated: true },
|
|
});
|
|
const note = area.querySelector(".xlsx-truncated");
|
|
assert.ok(note, "notice absent");
|
|
const txt = note.textContent;
|
|
assert.ok(txt.includes(FR["xlsx.truncated_title"]), txt);
|
|
// {shown} is the CAP (500), not the post-trim row count: a sparse sheet
|
|
// renders 1 row but the view still reaches 500 of them.
|
|
const expected = FR["xlsx.truncated_rows"].replace("{shown}", "500").replace("{total}", "1200");
|
|
assert.ok(txt.includes(expected), `${txt} !includes ${expected}`);
|
|
// Nothing to say about the columns here (12 < 40).
|
|
assert.ok(!txt.includes(FR["xlsx.truncated_cols"]), txt);
|
|
});
|
|
|
|
await test("notice mentions both axes when rows AND columns overflow", () => {
|
|
const area = mount({
|
|
sheet: { rows: 1, cols: 40, total_rows: 501, total_cols: 45, max_rows: 500, max_cols: 40, truncated: true },
|
|
});
|
|
const txt = area.querySelector(".xlsx-truncated").textContent;
|
|
assert.ok(
|
|
txt.includes(FR["xlsx.truncated_cols"].replace("{shown}", "40").replace("{total}", "45")),
|
|
txt
|
|
);
|
|
});
|
|
|
|
await test("a payload without the dimensions shows no notice", () => {
|
|
// Backward compatibility: an older cached response must not produce "NaN".
|
|
const area = mount({ sheet: { name: "Feuille1" } });
|
|
assert.equal(area.querySelector(".xlsx-truncated"), null);
|
|
assert.ok(!area.textContent.includes("NaN"));
|
|
});
|
|
|
|
// ── A9bis — lazy loading of the truncated tail ──────────────────────────────
|
|
|
|
const windowHtml = (from, to) =>
|
|
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
|
"<tbody>" +
|
|
Array.from({ length: to - from + 1 }, (_, i) => {
|
|
const r = from + i;
|
|
return `<tr><th class="xlsx-rownum">${r}</th><td data-cell="A${r}">Ligne ${r}</td></tr>`;
|
|
}).join("") +
|
|
"</tbody></table></div>";
|
|
|
|
await test("a truncated sheet gets a load-more footnote, a normal one does not", () => {
|
|
const truncated = mountTruncated();
|
|
assert.ok(truncated.querySelector(".xlsx-load-more"), "footnote absent");
|
|
assert.ok(truncated.querySelector(".xlsx-load-more").textContent.includes(FR["xlsx.load_more"]));
|
|
|
|
const plain = mount({ sheet: { rows: 10, cols: 2, total_rows: 10, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false } });
|
|
assert.equal(plain.querySelector(".xlsx-load-more"), null);
|
|
});
|
|
|
|
await test("clicking the footnote fetches the next window with the right query", async () => {
|
|
const area = mountTruncated();
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 2, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 3) },
|
|
});
|
|
area.querySelector(".xlsx-load-more").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/sheet\?path=data\.xlsx&sheet=Feuille1&offset=1&limit=500/);
|
|
// Rows 2 and 3 landed in the table with their real coordinates.
|
|
assert.ok(area.querySelector('td[data-cell="A2"]'));
|
|
assert.ok(area.querySelector('td[data-cell="A3"]'));
|
|
assert.ok(area.querySelector('th.xlsx-rownum') && area.textContent.includes("Ligne 3"));
|
|
// Everything loaded → the footnote is hidden (kept in the DOM, class `done`).
|
|
const foot = area.querySelector(".xlsx-load-more");
|
|
assert.ok(foot, "the footnote element survives");
|
|
assert.equal(foot.classList.contains("done"), true);
|
|
assert.ok(!foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent);
|
|
});
|
|
|
|
await test("appended rows are editable and tracked as dirty", async () => {
|
|
const area = mountTruncated();
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) },
|
|
});
|
|
area.querySelector(".xlsx-load-more").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
editCell(area, "A2", "modifié");
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.equal(calls.length, 2, "the save went out");
|
|
assert.equal(calls[1].url.includes("/xlsx/save"), true);
|
|
assert.deepEqual(calls[1].body.cells, { A2: "modifié" });
|
|
assert.equal(calls[1].body.sheet, "Feuille1");
|
|
});
|
|
|
|
await test("a failed window fetch keeps the footnote and shows an error toast", async () => {
|
|
const area = mountTruncated();
|
|
apiQueue.push({ ok: false, status: 500, body: { detail: "boom" } });
|
|
area.querySelector(".xlsx-load-more").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
const foot = area.querySelector(".xlsx-load-more");
|
|
assert.ok(foot, "footnote must survive a failed fetch");
|
|
assert.ok(foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent);
|
|
// `api()` itself toasts the failure (shared behaviour, asserted in E2E);
|
|
// here we assert the local consequence: the footnote keeps its label.
|
|
// Retrying works once the server answers again.
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) },
|
|
});
|
|
foot.click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.ok(area.querySelector('td[data-cell="A2"]'));
|
|
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
|
});
|
|
|
|
// ── A7 — formula bar & keyboard navigation ──────────────────────────────────
|
|
|
|
await test("the formula bar starts empty and disabled", () => {
|
|
const area = mount();
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "—");
|
|
assert.equal(area.querySelector("#xlsx-formula-input").disabled, true);
|
|
});
|
|
|
|
await test("focusing a cell shows its name and content in the bar", () => {
|
|
const area = mount();
|
|
const td = area.querySelector('td[data-cell="A1"]');
|
|
td.dispatchEvent(new w.Event("focus", { bubbles: false }));
|
|
// JSDOM does not run the default focus behaviour on dispatchEvent, so go
|
|
// through the real API:
|
|
td.focus();
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
|
|
assert.equal(area.querySelector("#xlsx-formula-input").value, "100");
|
|
assert.equal(area.querySelector("#xlsx-formula-input").disabled, false);
|
|
});
|
|
|
|
await test("typing in the bar edits the cell live and marks it dirty", () => {
|
|
const area = mount();
|
|
const td = area.querySelector('td[data-cell="A1"]');
|
|
td.focus();
|
|
const input = area.querySelector("#xlsx-formula-input");
|
|
input.value = "depuis la barre";
|
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
assert.equal(td.textContent, "depuis la barre");
|
|
assert.equal(td.classList.contains("xlsx-dirty"), true);
|
|
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
|
// The save payload carries the cell edit.
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
return new Promise((r) => setTimeout(r, 5)).then(() => {
|
|
assert.deepEqual(calls[0].body.cells, { A1: "depuis la barre" });
|
|
});
|
|
});
|
|
|
|
await test("Tab and arrows move to the neighbour cell", () => {
|
|
const area = mount();
|
|
const a1 = area.querySelector('td[data-cell="A1"]');
|
|
a1.focus();
|
|
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "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");
|
|
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");
|
|
});
|
|
|
|
await test("Enter commits and Shift+Tab goes backwards", () => {
|
|
const area = mount();
|
|
const a1 = area.querySelector('td[data-cell="A1"]');
|
|
a1.focus();
|
|
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
|
const b1 = area.querySelector('td[data-cell="B1"]');
|
|
b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
|
|
});
|
|
|
|
await test("a saved edit from the bar resets the dirty flag and orig value", async () => {
|
|
const area = mount();
|
|
const td = area.querySelector('td[data-cell="A1"]');
|
|
td.focus();
|
|
const input = area.querySelector("#xlsx-formula-input");
|
|
input.value = "200";
|
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
area.querySelector("#xlsx-save-btn").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
|
assert.equal(td.dataset.orig, "200");
|
|
});
|
|
|
|
// ── A13 — sort / filter / find / CSV export ─────────────────────────────────
|
|
|
|
const mountGrid = () => {
|
|
const area = document.getElementById("content-area");
|
|
area.innerHTML = "";
|
|
const grid =
|
|
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
|
'<thead><tr><th class="xlsx-corner"></th><th>A</th><th>B</th></tr></thead><tbody>' +
|
|
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">Banane</td><td data-cell="B1">3</td></tr>' +
|
|
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">Abricot</td><td data-cell="B2">10</td></tr>' +
|
|
'<tr><th class="xlsx-rownum">3</th><td data-cell="A3">Cerise</td><td data-cell="B3">2</td></tr>' +
|
|
"</tbody></table></div>";
|
|
renderXlsxViewer(area, {
|
|
vault: "V", path: "data.xlsx", is_xlsx: true,
|
|
xlsx_sheets: [{ name: "Fruits", html: grid, rows: 3, cols: 2, total_rows: 3, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false }],
|
|
xlsx_lossy_features: [],
|
|
});
|
|
return area;
|
|
};
|
|
|
|
await test("the context menu sorts the rows numerically or lexically", () => {
|
|
const area = mountGrid();
|
|
// Sort by column B (numbers) ascending: 2, 3, 10.
|
|
openMenuOn(area, "B1");
|
|
menuItem(FR["xlsx.sort_asc"]).click();
|
|
let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
|
assert.deepEqual(cells, ["2", "3", "10"]);
|
|
// Re-open and sort descending.
|
|
openMenuOn(area, "B1");
|
|
menuItem(FR["xlsx.sort_desc"]).click();
|
|
cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
|
assert.deepEqual(cells, ["10", "3", "2"]);
|
|
});
|
|
|
|
await test("a dirty cell travels with its row during a sort", () => {
|
|
const area = mountGrid();
|
|
editCell(area, "A3", "Cerise modifiée");
|
|
openMenuOn(area, "B1");
|
|
menuItem(FR["xlsx.sort_asc"]).click();
|
|
const aCells = [...area.querySelectorAll("tbody td[data-cell^=\"A\"]")].map((td) => td.textContent);
|
|
assert.ok(aCells.includes("Cerise modifiée"), aCells.join("|"));
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
|
});
|
|
|
|
await test("the filter hides the rows that do not match", () => {
|
|
const area = mountGrid();
|
|
// The filter reuses the find input: type and the rows filter live.
|
|
const input = area.querySelector("#xlsx-find-input");
|
|
input.value = "abri";
|
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
const visible = [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display !== "none");
|
|
assert.equal(visible.length, 1);
|
|
assert.ok(visible[0].textContent.includes("Abricot"));
|
|
});
|
|
|
|
await test("find highlights matches and navigates with the counter", () => {
|
|
const area = mountGrid();
|
|
const input = area.querySelector("#xlsx-find-input");
|
|
const count = area.querySelector("#xlsx-find-count");
|
|
input.value = "cerise"; // lowercase: the default search ignores the case
|
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
assert.equal(area.querySelectorAll("mark.xlsx-find-hit").length, 1);
|
|
assert.ok(count.textContent.includes("1/1"), count.textContent);
|
|
// The hit is inside the matching cell.
|
|
assert.ok(area.querySelector('td[data-cell="A3"] mark.xlsx-find-hit'));
|
|
});
|
|
|
|
await test("CSV export downloads the visible sheet without the cached shadows", () => {
|
|
const area = mountGrid();
|
|
const clicks = [];
|
|
const realCreate = document.createElement.bind(document);
|
|
const anchor = realCreate("a");
|
|
document.createElement = (tag) => {
|
|
if (tag === "a") { clicks.push(1); return anchor; }
|
|
return realCreate(tag);
|
|
};
|
|
let href = "";
|
|
Object.defineProperty(anchor, "href", { set(v) { href = v; }, get: () => href });
|
|
URL.createObjectURL = () => "blob:x";
|
|
URL.revokeObjectURL = () => {};
|
|
area.querySelector("#xlsx-csv-btn").click();
|
|
document.createElement = realCreate;
|
|
assert.equal(clicks.length, 1);
|
|
assert.equal(anchor.download, "Fruits.csv");
|
|
});
|
|
|
|
// ── A14 — structure menu ───────────────────────────────────────────────────
|
|
|
|
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
|
|
const area = mount();
|
|
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
|
apiQueue.push({
|
|
ok: true, status: 200,
|
|
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
|
}); // re-read
|
|
area.querySelector("#xlsx-structure-btn").click();
|
|
const addBtn = [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
|
addBtn.click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
// #154-A2 — a themed prompt replaces window.prompt().
|
|
const input = lastDialog()?.querySelector('[data-dialog="input"]');
|
|
assert.ok(input, "a themed prompt asks for the sheet name");
|
|
input.value = "Feuille 2";
|
|
clickDialog("confirm");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.equal(calls.length, 2);
|
|
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
|
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
|
assert.equal(calls[0].body.force, false);
|
|
assert.ok(
|
|
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
|
|
"the re-render shows the fresh payload",
|
|
);
|
|
});
|
|
|
|
await test("sheet_delete confirms and is refused on the last sheet", async () => {
|
|
const area = mount();
|
|
const delBtn = () => {
|
|
area.querySelector("#xlsx-structure-btn").click();
|
|
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
|
const b = items.find((x) => x.textContent === FR["xlsx.sheet_delete"]);
|
|
b.click();
|
|
};
|
|
// One sheet only → blocked before even confirming (no network call).
|
|
delBtn();
|
|
assert.equal(calls.length, 0, "nothing sent: last sheet");
|
|
});
|
|
|
|
await test("the 409 lossy flow re-emits with force after confirmation", async () => {
|
|
const area = mount();
|
|
// The prompt is answered through the themed dialog.
|
|
apiQueue.push({
|
|
ok: false, status: 409,
|
|
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
|
|
});
|
|
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // retry w/ force
|
|
apiQueue.push({
|
|
ok: true, status: 200,
|
|
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
|
|
});
|
|
area.querySelector("#xlsx-structure-btn").click();
|
|
[...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
|
|
clickDialog("confirm");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const lossyDialog = lastDialog();
|
|
assert.ok(lossyDialog && lossyDialog.textContent.includes(FR["xlsx.feature_slicers"]), "a lossy confirmation is shown");
|
|
clickDialog("confirm");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.equal(calls.length, 3);
|
|
assert.equal(calls[1].body.force, true);
|
|
});
|
|
|
|
// ── BUG-094 — structure actions work on an empty/new sheet ─────────────────
|
|
|
|
await test("the structure menu offers row/column actions without an active cell", () => {
|
|
const area = mount();
|
|
area.querySelector("#xlsx-structure-btn").click();
|
|
const labels = [...area.querySelectorAll(".xlsx-structure-item")].map((b) => b.textContent);
|
|
assert.ok(labels.includes(FR["xlsx.row_insert"]), "row insert available before any click");
|
|
assert.ok(labels.includes(FR["xlsx.col_insert"]), "column insert available before any click");
|
|
});
|
|
|
|
// ── A17 — dashboard panel ─────────────────────────────────────────────
|
|
await test("the dashboard button fetches the metadata and renders named ranges + KPIs", async () => {
|
|
const area = mount();
|
|
const dashBtn = area.querySelector("#xlsx-dashboard-btn");
|
|
assert.ok(dashBtn, "the dashboard button exists for an editable workbook");
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
vault: "V",
|
|
path: "data.xlsx",
|
|
named_ranges: [{ name: "MaPlage", scope: "", ref: "Data!$A$1:$B$5" }],
|
|
objects: { charts: 2, pivots: 1 },
|
|
sheets: [{ name: "Feuille1", cells: 9, rows: 3, cols: 3, formulas: 1, numeric: 2, kpi: [{ label: "A2", value: 12 }] }],
|
|
},
|
|
});
|
|
dashBtn.click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.match(calls[0].url, /\/xlsx\/dashboard\?path=data\.xlsx/);
|
|
const panel = area.querySelector(".xlsx-dashboard");
|
|
assert.ok(panel, "the dashboard panel is rendered");
|
|
assert.ok(panel.textContent.includes("MaPlage"), "named range is listed");
|
|
assert.ok(panel.textContent.includes("A2"), "KPI label is rendered");
|
|
assert.ok(panel.textContent.includes("12"), "KPI value is rendered");
|
|
// Second click closes the panel.
|
|
dashBtn.click();
|
|
assert.equal(area.querySelector(".xlsx-dashboard"), null, "panel toggles closed");
|
|
});
|
|
|
|
await test("the dashboard opens in the right-hand inspector, not inline in the grid", async () => {
|
|
const area = mount();
|
|
apiQueue.push({
|
|
ok: true, status: 200,
|
|
body: { vault: "V", path: "data.xlsx", named_ranges: [], objects: { charts: 0, pivots: 0 }, sheets: [{ name: "Feuille1", cells: 1, rows: 1, cols: 1, formulas: 0, numeric: 0, kpi: [] }] },
|
|
});
|
|
area.querySelector("#xlsx-dashboard-btn").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
const inspector = area.querySelector("#xlsx-inspector");
|
|
assert.equal(inspector.classList.contains("open"), true, "the inspector opens");
|
|
assert.equal(inspector.getAttribute("aria-hidden"), "false");
|
|
assert.ok(inspector.querySelector("#xlsx-inspector-body .xlsx-dashboard"), "the dashboard lives in the inspector");
|
|
// The grid is still present beside it (not replaced).
|
|
assert.ok(area.querySelector(".xlsx-main .xlsx-table"), "the grid stays visible");
|
|
// Closing removes the panel and closes the inspector.
|
|
area.querySelector("#xlsx-inspector-close").click();
|
|
assert.equal(area.querySelector(".xlsx-dashboard"), null);
|
|
assert.equal(inspector.classList.contains("open"), false);
|
|
assert.equal(inspector.getAttribute("aria-hidden"), "true");
|
|
});
|
|
|
|
await test("the inspector exposes an AI assistant entry point", () => {
|
|
const area = mount();
|
|
assert.ok(area.querySelector("#xlsx-inspector-ai"), "AI button in the inspector head");
|
|
const title = area.querySelector("#xlsx-inspector .xlsx-inspector-title");
|
|
assert.equal(title.textContent, FR["xlsx.inspector_title"]);
|
|
});
|
|
|
|
await test("a csv mounts without the dashboard button", () => {
|
|
const area = document.getElementById("content-area");
|
|
area.innerHTML = "";
|
|
renderXlsxViewer(area, {
|
|
vault: "V",
|
|
path: "data.csv",
|
|
is_csv: true,
|
|
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
|
xlsx_lossy_features: [],
|
|
});
|
|
assert.equal(area.querySelector("#xlsx-dashboard-btn"), null, "no dashboard for csv");
|
|
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure menu for csv");
|
|
assert.ok(area.querySelector("td[data-cell=\"A1\"]"), "the grid is still editable");
|
|
});
|
|
|
|
await test("two windows in a row walk the whole sheet", async () => {
|
|
const area = mountTruncated({ total: 1200 });
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 500, total_rows: 1200, truncated: true, has_more: true, html: windowHtml(2, 501) },
|
|
});
|
|
area.querySelector(".xlsx-load-more").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.ok(area.querySelector('td[data-cell="A501"]'));
|
|
// The footnote still shows, with the updated progress.
|
|
let foot = area.querySelector(".xlsx-load-more");
|
|
assert.ok(foot, "more rows remain");
|
|
assert.ok(foot.textContent.includes("501"), foot.textContent);
|
|
|
|
apiQueue.push({
|
|
ok: true,
|
|
status: 200,
|
|
body: { sheet: "Feuille1", offset: 501, limit: 500, rows: 200, total_rows: 1200, truncated: true, has_more: false, html: windowHtml(502, 701) },
|
|
});
|
|
foot.click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
assert.ok(area.querySelector('td[data-cell="A701"]'));
|
|
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
|
});
|
|
|
|
// ── #154-A1 — command bar, sheet tabs, status pills ─────────────────────────
|
|
|
|
await test("a single-sheet workbook still shows its tab and a + button", () => {
|
|
const area = mount();
|
|
assert.ok(area.querySelector(".xlsx-tabs"), "the tab strip is always rendered");
|
|
assert.equal(area.querySelectorAll(".xlsx-tab").length, 1);
|
|
const add = area.querySelector("#xlsx-tab-add");
|
|
assert.ok(add, "the + button is present for an editable sheet");
|
|
assert.equal(add.getAttribute("title"), FR["xlsx.tabs_add_sheet"]);
|
|
});
|
|
|
|
await test("a csv shows no tab strip and no + button", () => {
|
|
const area = document.getElementById("content-area");
|
|
area.innerHTML = "";
|
|
renderXlsxViewer(area, {
|
|
vault: "V", path: "data.csv", is_csv: true,
|
|
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
|
xlsx_lossy_features: [],
|
|
});
|
|
assert.equal(area.querySelector(".xlsx-tabs"), null);
|
|
assert.equal(area.querySelector("#xlsx-tab-add"), null);
|
|
});
|
|
|
|
await test("the + button adds a sheet through the structure endpoint", async () => {
|
|
const area = mount();
|
|
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
|
apiQueue.push({
|
|
ok: true, status: 200,
|
|
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
|
}); // re-read
|
|
area.querySelector("#xlsx-tab-add").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
|
|
clickDialog("confirm");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.equal(calls.length, 2);
|
|
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
|
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
|
assert.equal(area.querySelector('td[data-cell="A1"]')?.textContent, "neuf");
|
|
});
|
|
|
|
await test("a read-only workbook shows the read-only pill and offers no editing", () => {
|
|
const area = document.getElementById("content-area");
|
|
area.innerHTML = "";
|
|
renderXlsxViewer(area, {
|
|
vault: "V", path: "data.xls", is_xlsx: true, xlsx_readonly: true,
|
|
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
|
xlsx_lossy_features: [],
|
|
});
|
|
const pill = area.querySelector(".xlsx-status-readonly");
|
|
assert.ok(pill, "read-only pill");
|
|
assert.ok(pill.textContent.includes(FR["xlsx.readonly_badge"]), pill.textContent);
|
|
assert.equal(area.querySelector("#xlsx-tab-add"), null, "no + for read-only");
|
|
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure for read-only");
|
|
assert.equal(area.querySelector("#xlsx-undo-btn"), null, "no history for read-only");
|
|
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("contenteditable"), null, "cells are not editable");
|
|
});
|
|
|
|
await test("the formulas-not-recalculated pill is shown for xlsx but not csv", () => {
|
|
const area = mount();
|
|
const pill = area.querySelector(".xlsx-status-formula");
|
|
assert.ok(pill, "formula pill on xlsx");
|
|
assert.ok(pill.textContent.includes(FR["xlsx.formulas_note"]), pill.textContent);
|
|
|
|
const csvArea = document.getElementById("content-area");
|
|
csvArea.innerHTML = "";
|
|
renderXlsxViewer(csvArea, {
|
|
vault: "V", path: "data.csv", is_csv: true,
|
|
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
|
xlsx_lossy_features: [],
|
|
});
|
|
assert.equal(csvArea.querySelector(".xlsx-status-formula"), null);
|
|
});
|
|
|
|
await test("the command bar exposes grouped actions with a primary save", () => {
|
|
const area = mount();
|
|
assert.ok(area.querySelector(".xlsx-cmdbar"), "grouped command bar");
|
|
assert.ok(area.querySelector('#xlsx-save-btn').classList.contains("xlsx-save-primary"));
|
|
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='formulas']"));
|
|
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='insert']"));
|
|
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='file']"));
|
|
});
|
|
|
|
// ── #154-A4 — undo/redo, ARIA ───────────────────────────────────────────────
|
|
|
|
await test("undo/redo restores cell edits from the command stack", () => {
|
|
const area = mount();
|
|
const td = area.querySelector('td[data-cell="A1"]');
|
|
const undoBtn = area.querySelector("#xlsx-undo-btn");
|
|
const redoBtn = area.querySelector("#xlsx-redo-btn");
|
|
assert.equal(undoBtn.disabled, true, "nothing to undo yet");
|
|
td.focus();
|
|
editCell(area, "A1", "42");
|
|
td.dispatchEvent(new w.Event("blur"));
|
|
assert.equal(undoBtn.disabled, false, "undo becomes available after a committed edit");
|
|
undoBtn.click();
|
|
assert.equal(td.textContent, "100", "undo restores the original value");
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0, "the cell is clean again");
|
|
assert.equal(redoBtn.disabled, false, "redo becomes available");
|
|
redoBtn.click();
|
|
assert.equal(td.textContent, "42", "redo reapplies the edit");
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1, "the edit is dirty again");
|
|
});
|
|
|
|
await test("a no-op undo/redo pair leaves the buttons disabled", () => {
|
|
const area = mount();
|
|
assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true);
|
|
assert.equal(area.querySelector("#xlsx-redo-btn").disabled, true);
|
|
});
|
|
|
|
await test("the grid exposes ARIA grid semantics", () => {
|
|
const area = mount();
|
|
const table = area.querySelector(".xlsx-table");
|
|
assert.equal(table.getAttribute("role"), "grid");
|
|
assert.equal(area.querySelector(".xlsx-table thead th").getAttribute("role"), "columnheader");
|
|
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("role"), "gridcell");
|
|
assert.equal(area.querySelector("th.xlsx-rownum").getAttribute("role"), "rowheader");
|
|
});
|
|
|
|
// ── #154-A5 — extracted refs module & dashboard → grid link ────────────────
|
|
|
|
const { parseRef, columnName, firstCellOfRange, sheetOfRef } = await import(
|
|
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "xlsx", "refs.js")).href
|
|
);
|
|
|
|
await test("parseRef / columnName round-trip A1 references", () => {
|
|
assert.deepEqual(parseRef("A1"), { row: 1, col: 1 });
|
|
assert.deepEqual(parseRef("B12"), { row: 12, col: 2 });
|
|
assert.deepEqual(parseRef("AA3"), { row: 3, col: 27 });
|
|
assert.equal(parseRef("A"), null);
|
|
assert.equal(columnName(1), "A");
|
|
assert.equal(columnName(26), "Z");
|
|
assert.equal(columnName(27), "AA");
|
|
assert.equal(columnName(parseRef("AB9").col), "AB");
|
|
});
|
|
|
|
await test("range helpers extract the sheet and the first cell", () => {
|
|
assert.equal(firstCellOfRange("Data!$A$1:$B$5"), "A1");
|
|
assert.equal(firstCellOfRange("'Mon onglet'!$C$3"), "C3");
|
|
assert.equal(firstCellOfRange("A1:B2"), "A1");
|
|
assert.equal(sheetOfRef("Data!$A$1:$B$5"), "Data");
|
|
assert.equal(sheetOfRef("'Mon onglet'!$C$3"), "Mon onglet");
|
|
assert.equal(sheetOfRef("A1:B2"), null);
|
|
});
|
|
|
|
await test("clicking a named range reveals its first cell in the grid", async () => {
|
|
const area = mount();
|
|
apiQueue.push({
|
|
ok: true, status: 200,
|
|
body: { vault: "V", path: "data.xlsx", named_ranges: [{ name: "MaPlage", scope: "", ref: "Feuille1!$A$1:$B$2" }], objects: { charts: 0, pivots: 0 }, sheets: [] },
|
|
});
|
|
area.querySelector("#xlsx-dashboard-btn").click();
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
const row = area.querySelector(".xlsx-range-row[data-ref]");
|
|
assert.ok(row, "the named range row is rendered");
|
|
row.click();
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1", "the anchor cell becomes active");
|
|
assert.ok(area.querySelector('td[data-cell="A1"]').classList.contains("xlsx-active"));
|
|
});
|
|
|
|
await test("the inspector exposes a resize handle", () => {
|
|
const area = mount();
|
|
assert.ok(area.querySelector("#xlsx-inspector-resize"), "resize handle present");
|
|
});
|
|
|
|
// ── #155 — selection & context menu ─────────────────────────────────────────
|
|
|
|
await test("a right-click on a cell opens a themed context menu", () => {
|
|
const area = mount();
|
|
const menu = openMenuOn(area, "A1");
|
|
assert.ok(menu, "menu rendered");
|
|
assert.ok(menuItem(FR["xlsx.insert_row_above"]), "insert row item");
|
|
assert.ok(menuItem(FR["xlsx.sort_asc"]), "sort item");
|
|
assert.ok(menuItem(FR["xlsx.clear_contents"]), "clear item");
|
|
});
|
|
|
|
await test("the context menu inserts a row through the structure endpoint", async () => {
|
|
const area = mount();
|
|
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
|
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: [] },
|
|
}); // re-read
|
|
openMenuOn(area, "A2");
|
|
menuItem(FR["xlsx.insert_row_above"]).click();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert.equal(calls.length, 2);
|
|
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
|
assert.deepEqual(calls[0].body.actions, [{ op: "row_insert", sheet: "Feuille1", at: 2, count: 1 }]);
|
|
});
|
|
|
|
await test("clear contents empties the selected cells (local edit)", () => {
|
|
const area = mount();
|
|
openMenuOn(area, "A1");
|
|
menuItem(FR["xlsx.clear_contents"]).click();
|
|
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "");
|
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
|
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
|
});
|
|
|
|
await test("clicking a column header selects the whole column", () => {
|
|
const area = mount();
|
|
area.querySelector(".xlsx-table thead th:nth-child(2)").click(); // column A
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:A2");
|
|
const selected = [...area.querySelectorAll("td.xlsx-selected")].map((td) => td.dataset.cell).sort();
|
|
assert.deepEqual(selected, ["A1", "A2"]);
|
|
});
|
|
|
|
await test("clicking a row header selects the whole row", () => {
|
|
const area = mount();
|
|
area.querySelector("th.xlsx-rownum").click(); // row 1
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
|
|
});
|
|
|
|
await test("dragging from a cell to another selects the range", () => {
|
|
const area = mount();
|
|
const viewer = area.querySelector(".xlsx-viewer");
|
|
const a1 = area.querySelector('td[data-cell="A1"]');
|
|
const b2 = area.querySelector('td[data-cell="B2"]');
|
|
a1.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
|
b2.dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true, button: 0 }));
|
|
viewer.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B2");
|
|
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4);
|
|
});
|
|
|
|
await test("Shift+Arrow extends the selection from the anchor", () => {
|
|
const area = mount();
|
|
const a1 = area.querySelector('td[data-cell="A1"]');
|
|
a1.focus();
|
|
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true, bubbles: true }));
|
|
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
|
|
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 2);
|
|
});
|
|
|
|
await test("the context menu module exposes open/close helpers", async () => {
|
|
const mod = await import(
|
|
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")).href
|
|
);
|
|
const menu = mod.openContextMenu(5, 5, [{ label: "Only" }]);
|
|
assert.equal(menu.querySelectorAll(".xlsx-context-item").length, 1);
|
|
assert.equal(mod.isContextMenuOpen(), true);
|
|
mod.closeContextMenu();
|
|
assert.equal(mod.isContextMenuOpen(), false);
|
|
});
|
|
|
|
// ── Report ──────────────────────────────────────────────────────────────────
|
|
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
|
process.exit(passCount === testCount ? 0 : 1);
|