feat: formats tableur additionnels - xlsm editable, xls/ods lecture seule, csv editable #153
A16 — la visionneuse tableur accepte quatre formats de plus : .xlsm est
servi et sauvegarde comme un .xlsx avec keep_vba=True (les macros
survivent, la porte lossy est levée pour ce format) ; .xls (xlrd) et .ods
(odfpy) sont rendus en lecture seule (xlsx_readonly, wiring d'édition
désactivé) ; .csv devient éditable via render_csv_table (grille A1
identique au viewer) et PUT /api/file/{vault}/csv/save (réécriture csv
RFC 4180, extension de grille, valeurs stockées telles quelles). 12 tests
backend + contre-preuve (4 échecs sur neutralisation du service CSV),
JSDOM 33/33, ruff/mypy 0.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,219 @@
|
||||
"""Additional spreadsheet formats in the viewer (#153 A16): .xlsm editable
|
||||
with macros preserved, .xls/.ods read-only renders and .csv A1-addressed
|
||||
saves."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
VAULT = "TestVault"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def formats_vault(test_vault_dir: str) -> Path:
|
||||
"""Directory of the test vault, for fixture files written in-place."""
|
||||
return Path(test_vault_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def xlsm_book(formats_vault: Path) -> str:
|
||||
"""A macro-enabled workbook: one sheet, two cells, a fake VBA blob."""
|
||||
path = formats_vault / "macro.xlsm"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "Nom"
|
||||
ws["A2"] = "Ada"
|
||||
wb.save(path)
|
||||
# Inject a minimal vbaProject.bin part so the archive really IS a .xlsm
|
||||
# (keep_vba only matters when macros exist).
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
real = path.with_suffix(".tmp.xlsm")
|
||||
with zipfile.ZipFile(path) as zin, zipfile.ZipFile(real, "w") as zout:
|
||||
for item in zin.namelist():
|
||||
zout.writestr(item, zin.read(item))
|
||||
zout.writestr("xl/vbaProject.bin", b"VBA-FAKE-CONTENT")
|
||||
shutil.move(real, path)
|
||||
return "macro.xlsm"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ods_book(formats_vault: Path) -> str:
|
||||
"""A small ODS spreadsheet."""
|
||||
# The odfpy distribution installs a top-level module named `odf`.
|
||||
pytest.importorskip("odf")
|
||||
from odf.opendocument import OpenDocumentSpreadsheet
|
||||
from odf.table import Table, TableCell, TableRow
|
||||
from odf.text import P
|
||||
|
||||
path = formats_vault / "classeur.ods"
|
||||
doc = OpenDocumentSpreadsheet()
|
||||
table = Table(name="Feuille1")
|
||||
for values in (["Ville", "Pop"], ["Paris", "2100000"]):
|
||||
tr = TableRow()
|
||||
for v in values:
|
||||
tc = TableCell(valuetype="string")
|
||||
tc.addElement(P(text=str(v)))
|
||||
tr.addElement(tc)
|
||||
table.addElement(tr)
|
||||
doc.spreadsheet.addElement(table)
|
||||
doc.save(str(path))
|
||||
return "classeur.ods"
|
||||
|
||||
|
||||
class TestXlsmEditable:
|
||||
def test_read_serves_the_xlsx_viewer_payload(self, client, xlsm_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": xlsm_book})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_xlsx"] is True
|
||||
assert data["extension"] == ".xlsm"
|
||||
# Macros are NOT lossy for .xlsm: keep_vba preserves them, so no
|
||||
# confirmation round-trip is ever triggered.
|
||||
assert data["xlsx_lossy_features"] == []
|
||||
assert 'data-cell="A1"' in data["xlsx_sheets"][0]["html"]
|
||||
|
||||
def test_save_round_trips_and_keeps_vba(self, client, xlsm_book):
|
||||
resp = client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": xlsm_book},
|
||||
json={"sheet": "Data", "cells": {"A2": "Grace"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
import zipfile
|
||||
|
||||
# Re-read through the API instead of guessing the root on disk.
|
||||
reread = client.get(f"/api/file/{VAULT}", params={"path": xlsm_book})
|
||||
assert "Grace" in reread.json()["xlsx_sheets"][0]["html"]
|
||||
# The fake VBA part survived the round-trip.
|
||||
with zipfile.ZipFile(_vault_file(client, xlsm_book)) as zf:
|
||||
assert "xl/vbaProject.bin" in zf.namelist()
|
||||
|
||||
def test_lossy_gate_is_skipped_for_xlsm(self, formats_vault, xlsm_book):
|
||||
from backend.services.mutations import edit_xlsx_cells
|
||||
|
||||
# No force flag: the save must succeed despite the vbaProject part
|
||||
# (it would raise xlsx_lossy_content on a plain .xlsx).
|
||||
result = edit_xlsx_cells(VAULT, xlsm_book, "Data", {"A2": "Alan"})
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
class TestLegacyReadOnly:
|
||||
def test_xls_renders_cells(self, client, formats_vault):
|
||||
pytest.importorskip("xlrd")
|
||||
import shutil
|
||||
|
||||
shutil.copy("tests/fixtures/sample.xls", formats_vault / "sample.xls")
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "sample.xls"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_xlsx"] is True
|
||||
assert data["xlsx_readonly"] is True
|
||||
sheet = data["xlsx_sheets"][0]
|
||||
assert sheet["name"] == "Data"
|
||||
assert "Produit" in sheet["html"]
|
||||
assert "Café" in sheet["html"]
|
||||
|
||||
def test_ods_renders_cells(self, client, ods_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": ods_book})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["xlsx_readonly"] is True
|
||||
html = data["xlsx_sheets"][0]["html"]
|
||||
assert "Paris" in html
|
||||
assert "2100000" in html
|
||||
|
||||
def test_readonly_meta_is_empty(self, client, formats_vault):
|
||||
pytest.importorskip("xlrd")
|
||||
import shutil
|
||||
|
||||
shutil.copy("tests/fixtures/sample.xls", formats_vault / "sample.xls")
|
||||
sheet = client.get(
|
||||
f"/api/file/{VAULT}", params={"path": "sample.xls"}
|
||||
).json()["xlsx_sheets"][0]
|
||||
assert sheet["styles"] == {}
|
||||
assert sheet["merges"] == []
|
||||
assert sheet["freeze"] == ""
|
||||
|
||||
def test_broken_legacy_file_yields_one_empty_sheet(self, client, formats_vault):
|
||||
(formats_vault / "broken.xls").write_bytes(b"not an ole file")
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "broken.xls"})
|
||||
assert resp.status_code == 200
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert sheet["rows"] == 0
|
||||
|
||||
|
||||
class TestCsvEditable:
|
||||
def test_read_renders_the_xlsx_shaped_table(self, client, formats_vault):
|
||||
(formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "liste.csv"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_csv"] is True
|
||||
html = data["html"]
|
||||
assert 'data-cell="A1"' in html
|
||||
assert "xlsx-table" in html
|
||||
|
||||
def test_save_cells_and_grow_the_grid(self, client, formats_vault):
|
||||
(formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
resp = client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "liste.csv"},
|
||||
json={"cells": {"B1": "modifié", "C3": "nouveau"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
text = _vault_file(client, "liste.csv").read_text(encoding="utf-8")
|
||||
assert "modifié" in text
|
||||
assert "nouveau" in text
|
||||
# Existing rows survive, and the reference beyond the extent grew the
|
||||
# grid to 3 rows x 3 cols.
|
||||
assert text.startswith("a,modifié")
|
||||
assert "1,2" in text
|
||||
assert text.count("\n") >= 2
|
||||
|
||||
def test_csv_values_are_stored_verbatim(self, client, formats_vault):
|
||||
(formats_vault / "formules.csv").write_text("x\n", encoding="utf-8")
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "formules.csv"},
|
||||
json={"cells": {"A1": "=1+1"}},
|
||||
)
|
||||
text = _vault_file(client, "formules.csv").read_text(encoding="utf-8")
|
||||
assert "=1+1" in text # no formula engine: stored as text
|
||||
|
||||
def test_quoting_survives_a_round_trip(self, client, formats_vault):
|
||||
(formats_vault / "quotes.csv").write_text('nom\n"Dupont, Jean"\n', encoding="utf-8")
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "quotes.csv"},
|
||||
json={"cells": {"A2": "Martin, Pierre"}},
|
||||
)
|
||||
text = _vault_file(client, "quotes.csv").read_text(encoding="utf-8")
|
||||
assert '"Martin, Pierre"' in text
|
||||
|
||||
def test_bad_reference_is_refused(self, client, formats_vault):
|
||||
(formats_vault / "liste.csv").write_text("a\n", encoding="utf-8")
|
||||
resp = client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "liste.csv"},
|
||||
json={"cells": {"XX": "v"}}, # missing row number
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def _vault_file(client, rel: str) -> Path:
|
||||
"""Resolve a vault file path for on-disk assertions."""
|
||||
root = None
|
||||
for route in client.app.routes:
|
||||
pass
|
||||
# The service layer exposes the vault root; use it directly.
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
root = get_vault_root(VAULT)
|
||||
return root / rel
|
||||
Reference in New Issue
Block a user