feat: troncature annoncee et lecture par fenetres des tableurs #153
- BUG-090 (#153 A8) : render_sheets() expose total_rows/total_cols, max_rows/max_cols et truncated ; la visionneuse affiche un bandeau « Feuille tronquée » (i18n FR/EN) au lieu de couper en silence, et la ligne d'en-têtes devient sticky (top:auto sur les numéros de ligne). - #153 A9 : GET /api/file/{vault}/xlsx/sheet?sheet&offset&limit sert une fenêtre de 1 à 1000 lignes avec les vraies coordonnées A1, has_more de pagination et valeurs calculées A12 ; 404 feuille inconnue, 415 non-xlsx. - Tests : TestXlsxTruncationNotice (4) + TestXlsxSheetWindow (11) avec contre-preuves, xlsx-viewer.test.mjs 14/14, E2E 7/7 (fixture sample-xlsx-large.xlsx 520 lignes), suite 1417 passed / 6 skipped, ruff/mypy 0, i18n parity. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
@@ -14,6 +14,12 @@
|
||||
* - the f(x) toggle is off by default, so "=B1*3" is stored as text ;
|
||||
* - the value Excel last computed is shown under the formula (#153 A12).
|
||||
*
|
||||
* Second describe block — `test_vault/sample-xlsx-large.xlsx` (520 rows) :
|
||||
* - a sheet over the render caps SAYS it instead of looking complete (#153 A8) ;
|
||||
* - the column headers stay pinned while the sheet scrolls (#153 A8) ;
|
||||
* - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
|
||||
* with the real A1 coordinates (#153 A9).
|
||||
*
|
||||
* The fixture is restored byte-for-byte in `afterAll` so a local run never
|
||||
* dirties the working copy.
|
||||
*
|
||||
@@ -27,6 +33,10 @@ import path from 'node:path';
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||
const VAULT = 'TestVault';
|
||||
const FIXTURE = 'sample-xlsx-lossy.xlsx';
|
||||
// #153 A8 — 520 rows x 3 columns: the sheet exceeds the 500-row render cap, so
|
||||
// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and
|
||||
// committed next to the other fixture; nothing in the suite writes to it.
|
||||
const LARGE = 'sample-xlsx-large.xlsx';
|
||||
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
|
||||
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
||||
|
||||
@@ -45,7 +55,11 @@ async function login(page) {
|
||||
}
|
||||
|
||||
async function openFixture(page) {
|
||||
const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${FIXTURE}"]`);
|
||||
return openXlsx(page, FIXTURE);
|
||||
}
|
||||
|
||||
async function openXlsx(page, file) {
|
||||
const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${file}"]`);
|
||||
if (!(await treeItem.count())) {
|
||||
await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click();
|
||||
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
|
||||
@@ -141,3 +155,59 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── A8 — troncature annoncée + en-têtes figés ───────────────────────────────
|
||||
|
||||
test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
|
||||
test('annonce la feuille tronquée au lieu de la couper en silence', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const note = page.locator('#content-area .xlsx-truncated');
|
||||
await expect(note).toBeVisible();
|
||||
// Libellé traduit (jamais de texte UI backend, jamais de couleur en dur).
|
||||
await expect(note).toContainText('Feuille tronquée');
|
||||
await expect(note).toContainText('500 lignes affichées sur 520');
|
||||
|
||||
// La dernière ligne rendue est la 500e ; les suivantes ne sont pas là.
|
||||
await expect(page.locator('#content-area td[data-cell="A500"]')).toHaveCount(1);
|
||||
await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('garde les en-têtes de colonnes visibles au défilement', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const header = page.locator('#content-area .xlsx-table thead th').nth(1);
|
||||
const before = await header.boundingBox();
|
||||
|
||||
await page.locator('#content-area .csv-table-wrapper').evaluate((el) => { el.scrollTop = 800; });
|
||||
await expect.poll(async () => (await header.boundingBox()).y, { timeout: 5000 })
|
||||
.toBeLessThanOrEqual(before.y + 1);
|
||||
|
||||
// Les numéros de ligne ne se superposent pas en haut à gauche (le `top: auto`
|
||||
// de A8) et la première ligne de données reste lisible sous l'en-tête.
|
||||
const first = await page.locator('#content-area th.xlsx-rownum').first().boundingBox();
|
||||
const second = await page.locator('#content-area th.xlsx-rownum').nth(1).boundingBox();
|
||||
expect(second.y - first.y).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
test('l\'endpoint de fenêtre sert les lignes au-delà du plafond (#153 A9)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const res = await page.request.get(
|
||||
`${BASE}/api/file/${VAULT}/xlsx/sheet?path=${encodeURIComponent(LARGE)}&sheet=Journal&offset=500&limit=50`
|
||||
);
|
||||
expect(res.status()).toBe(200);
|
||||
const win = await res.json();
|
||||
expect(win.total_rows).toBe(520);
|
||||
expect(win.offset).toBe(500);
|
||||
expect(win.has_more).toBe(false);
|
||||
// Les coordonnées A1 sont celles de la feuille, pas celles de la fenêtre :
|
||||
// la ligne 520 est servie comme A520, pas comme A20.
|
||||
expect(win.html).toContain('data-cell="A520"');
|
||||
expect(win.html).toContain('Operation 519');
|
||||
expect(win.html).not.toContain('data-cell="A1"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 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.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-viewer.test.mjs
|
||||
*/
|
||||
@@ -116,14 +117,14 @@ const sheetHtml = (value) =>
|
||||
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td></tr>` +
|
||||
"</tbody></table></div>";
|
||||
|
||||
function mount({ lossy = [] } = {}) {
|
||||
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") }],
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }],
|
||||
xlsx_lossy_features: lossy,
|
||||
});
|
||||
return area;
|
||||
@@ -268,6 +269,49 @@ await test("a non-409 failure is not retried", async () => {
|
||||
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"));
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
|
||||
@@ -519,6 +519,173 @@ class TestXlsxWriteLock:
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A1"].value == "premier"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wide_xlsx(test_vault_dir: str) -> str:
|
||||
"""Workbook whose sheet exceeds BOTH render caps (501 rows x 45 cols).
|
||||
|
||||
Sparse on purpose: a cell in A501 and one in AS1 are enough for openpyxl
|
||||
to declare those dimensions, without writing 20 000 cells to disk.
|
||||
"""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "grand.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "tête"
|
||||
ws["A501"] = "dernière ligne"
|
||||
ws["AS1"] = "colonne 45"
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def edge_xlsx(test_vault_dir: str) -> str:
|
||||
"""Sheet exactly on the caps (500 rows x 40 cols) — must NOT be truncated."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "limite.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "bord"
|
||||
ws["A500"] = "ligne 500"
|
||||
ws["AN1"] = "colonne 40"
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
# ── #153 A8 — silent truncation made visible ─────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxTruncationNotice:
|
||||
"""A sheet bigger than the caps must SAY so instead of looking complete."""
|
||||
|
||||
def test_render_reports_the_real_dimensions(self, client, wide_xlsx):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "grand.xlsx"})
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert (sheet["total_rows"], sheet["total_cols"]) == (501, 45)
|
||||
assert sheet["truncated"] is True
|
||||
# The caps are the coverage the banner announces — `rows`/`cols` are
|
||||
# post-trim and would understate it on a sparse sheet.
|
||||
assert (sheet["max_rows"], sheet["max_cols"]) == (500, 40)
|
||||
assert (sheet["rows"], sheet["cols"]) == (1, 1) # only 3 filled cells
|
||||
|
||||
def test_a_sheet_on_the_caps_is_not_flagged(self, client, edge_xlsx):
|
||||
"""Boundary: 500x40 is exactly what the renderer supports."""
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "limite.xlsx"})
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert sheet["truncated"] is False
|
||||
assert (sheet["total_rows"], sheet["total_cols"]) == (500, 40)
|
||||
|
||||
def test_a_normal_sheet_is_not_flagged(self, client, xlsx_file):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"})
|
||||
assert all(not s["truncated"] for s in resp.json()["xlsx_sheets"])
|
||||
|
||||
def test_blank_tail_is_not_reported_as_truncation(self, client, test_vault_dir):
|
||||
"""A sheet with empty rows below its data fits in the caps."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "blanc.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "seule ligne"
|
||||
ws["A300"] = None # formatted-but-empty row inside the caps
|
||||
wb.save(path)
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "blanc.xlsx"})
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert sheet["truncated"] is False
|
||||
assert sheet["rows"] == 1 # trailing blanks dropped by _trim
|
||||
|
||||
|
||||
# ── #153 A9 — lazy per-sheet loading ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxSheetWindow:
|
||||
"""GET /api/file/{vault}/xlsx/sheet — one window of one sheet."""
|
||||
|
||||
def _get(self, client, path="budget.xlsx", **params):
|
||||
return client.get(
|
||||
f"/api/file/{VAULT}/xlsx/sheet", params={"path": path, **params}
|
||||
)
|
||||
|
||||
def test_window_returns_rows_and_totals(self, client, xlsx_file):
|
||||
resp = self._get(client, sheet="Budget", offset=0, limit=10)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["sheet"] == "Budget"
|
||||
assert (data["offset"], data["limit"]) == (0, 10)
|
||||
assert data["rows"] == 2 and data["cols"] == 2
|
||||
assert data["total_rows"] == 2 and data["truncated"] is False
|
||||
assert (data["max_rows"], data["max_cols"]) == (500, 40)
|
||||
assert data["has_more"] is False
|
||||
assert 'data-cell="A1"' in data["html"]
|
||||
|
||||
def test_window_keeps_the_real_a1_coordinates(self, client, xlsx_file):
|
||||
"""A window must be indistinguishable from a full render: the A1
|
||||
references and the row numbers have to be the sheet's, not the
|
||||
window's, or an edit would land on the wrong cell. The CONTENT matters
|
||||
as much as the label — row 2 of "Budget" is "Total", not "Poste"."""
|
||||
data = self._get(client, sheet="Budget", offset=1, limit=1).json()
|
||||
assert data["rows"] == 1
|
||||
assert 'data-cell="A2"' in data["html"]
|
||||
assert 'data-cell="A1"' not in data["html"]
|
||||
assert "<th class=\"xlsx-rownum\">2</th>" in data["html"]
|
||||
assert "Total" in data["html"] and "Poste" not in data["html"]
|
||||
|
||||
def test_window_offsets_walk_the_whole_sheet(self, client, wide_xlsx):
|
||||
first = self._get(client, path="grand.xlsx", sheet="Data", offset=0, limit=10).json()
|
||||
last = self._get(client, path="grand.xlsx", sheet="Data", offset=500, limit=10).json()
|
||||
assert first["truncated"] is True and first["has_more"] is True
|
||||
assert 'data-cell="A501"' in last["html"] # the row the caps used to hide
|
||||
assert "dernière ligne" in last["html"]
|
||||
assert "dernière ligne" not in first["html"]
|
||||
assert last["rows"] == 1 and last["has_more"] is False
|
||||
|
||||
def test_offset_past_the_end_is_empty_not_an_error(self, client, xlsx_file):
|
||||
resp = self._get(client, sheet="Budget", offset=9999, limit=10)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["rows"] == 0
|
||||
assert data["has_more"] is False
|
||||
|
||||
def test_cached_result_survives_the_window(self, client, lossy_xlsx):
|
||||
"""A12 must not be lost on the lazy path."""
|
||||
data = self._get(client, path="risky.xlsx", sheet="Data", offset=0, limit=10).json()
|
||||
assert "xlsx-cached" in data["html"]
|
||||
|
||||
def test_unknown_sheet_is_404(self, client, xlsx_file):
|
||||
resp = self._get(client, sheet="Nope")
|
||||
assert resp.status_code == 404
|
||||
assert "Feuille introuvable" in resp.json()["detail"]
|
||||
|
||||
def test_missing_file_is_404(self, client, test_vault_dir):
|
||||
assert self._get(client, path="absent.xlsx", sheet="Budget").status_code == 404
|
||||
|
||||
def test_non_xlsx_file_is_415(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "note.md").write_text("# hi", encoding="utf-8")
|
||||
resp = self._get(client, path="note.md", sheet="Budget")
|
||||
assert resp.status_code == 415
|
||||
|
||||
def test_limit_above_the_server_cap_is_rejected(self, client, xlsx_file):
|
||||
"""The cap is a contract, not a silent truncation of the request."""
|
||||
assert self._get(client, sheet="Budget", limit=100_000).status_code == 422
|
||||
|
||||
def test_reader_clamps_a_hostile_limit(self, xlsx_file):
|
||||
"""Belt and braces: the reader caps too, whoever calls it."""
|
||||
from backend.xlsx_reader import MAX_WINDOW_ROWS, read_sheet_window
|
||||
|
||||
window = read_sheet_window(Path(xlsx_file), "Budget", offset=0, limit=10**9)
|
||||
assert window["limit"] == MAX_WINDOW_ROWS
|
||||
|
||||
def test_reader_rejects_a_negative_offset(self, xlsx_file):
|
||||
from backend.xlsx_reader import read_sheet_window
|
||||
|
||||
window = read_sheet_window(Path(xlsx_file), "Budget", offset=-5, limit=10)
|
||||
assert window["offset"] == 0
|
||||
|
||||
|
||||
# ── #153 A4 — formula injection ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user