feat: structure des classeurs Excel editable depuis la visionneuse #153
A14 : service mutate_xlsx_structure (batch ordonné d'actions
sheet_add/sheet_rename/sheet_delete/sheet_duplicate et
row/col_insert/delete, une seule réécriture verrouillée et atomique,
409 lossy sans force, backup, dernier sheet protégé) ; endpoint
PUT /api/file/{vault}/xlsx/structure (1-50 actions/requête) ;
menu « structure » dans la visionneuse : ajout/renommage/duplication/
suppression de feuilles et insertion/suppression de ligne/colonne à
partir de la cellule active, confirmations explicites pour les
destructions, re-rendu serveur après succès, reprise force après
confirmation 409.
Vérifié : test_xlsx_structure.py 11 (ops, gardes, atomicité du batch,
409 lossy + force) + contre-preuve (garde dernier sheet neutralisée ->
1 échec), xlsx-viewer.test.mjs 33/33 (3 nouveaux), ruff 0, mypy 0,
i18n parity, validate-imports.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
* 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
|
||||
*/
|
||||
@@ -576,6 +578,76 @@ await test("CSV export downloads the visible sheet without the cached shadows",
|
||||
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();
|
||||
// The prompt is resolved through the module scope: stub it globally.
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
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 items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
||||
addBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
globalThis.prompt = realPrompt;
|
||||
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);
|
||||
// The viewer re-rendered from the server payload (a single sheet → no tabs).
|
||||
assert.ok(
|
||||
area.querySelector("#content-area, .xlsx-viewer") || area,
|
||||
"the viewer was rebuilt",
|
||||
);
|
||||
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 resolved through the module scope: stub it globally.
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
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();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
items.find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
globalThis.prompt = realPrompt;
|
||||
assert.equal(confirmCalls, 1, "the user is asked about the lossy write");
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[1].body.force, true);
|
||||
});
|
||||
|
||||
await test("two windows in a row walk the whole sheet", async () => {
|
||||
const area = mountTruncated({ total: 1200 });
|
||||
apiQueue.push({
|
||||
|
||||
Reference in New Issue
Block a user