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
295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""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_sheet_window_is_served_for_xlsm(self, client, xlsm_book):
|
|
"""BUG-097 — a .xlsm is editable, so its lazy row windows must work."""
|
|
resp = client.get(
|
|
f"/api/file/{VAULT}/xlsx/sheet",
|
|
params={"path": xlsm_book, "sheet": "Data", "offset": 0, "limit": 100},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["sheet"] == "Data"
|
|
assert 'data-cell="A2"' in body["html"]
|
|
assert body["has_more"] is False
|
|
|
|
def test_sheet_window_still_refuses_other_formats(self, client, formats_vault):
|
|
(formats_vault / "plain.csv").write_text("a,b\n", encoding="utf-8")
|
|
resp = client.get(
|
|
f"/api/file/{VAULT}/xlsx/sheet",
|
|
params={"path": "plain.csv", "sheet": "Feuille1"},
|
|
)
|
|
assert resp.status_code == 415
|
|
|
|
def test_lossy_gate_is_skipped_for_xlsm(self, client, formats_vault, xlsm_book):
|
|
from backend.services.mutations import edit_xlsx_cells
|
|
|
|
# `client` is required: the service resolves the vault through the app
|
|
# registry (the test used to pass only thanks to state leaked by an
|
|
# earlier test in this class).
|
|
# 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 TestCsvDelimiter:
|
|
"""BUG-098 — the delimiter is detected, not assumed to be a comma."""
|
|
|
|
def test_sniffing_handles_the_common_separators(self):
|
|
from backend.xlsx_reader import sniff_csv_delimiter
|
|
|
|
assert sniff_csv_delimiter("a;b\n1;2\n") == ";"
|
|
assert sniff_csv_delimiter("a,b\n1,2\n") == ","
|
|
assert sniff_csv_delimiter("a\tb\n1\t2\n") == "\t"
|
|
|
|
def test_single_column_falls_back_to_a_comma(self):
|
|
from backend.xlsx_reader import sniff_csv_delimiter
|
|
|
|
assert sniff_csv_delimiter("juste\nune\ncolonne\n") == ","
|
|
|
|
|
|
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_semicolon_csv_renders_multiple_columns(self, client, formats_vault):
|
|
"""BUG-098 — a French (semicolon) CSV is no longer a single column."""
|
|
(formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8")
|
|
html = client.get(
|
|
f"/api/file/{VAULT}", params={"path": "fr.csv"}
|
|
).json()["html"]
|
|
assert 'data-cell="B1"' in html and 'data-cell="B2"' in html
|
|
assert "Paris" in html
|
|
|
|
def test_semicolon_is_preserved_when_saving(self, client, formats_vault):
|
|
"""BUG-098 — the write-back keeps the file's own delimiter."""
|
|
(formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8")
|
|
resp = client.put(
|
|
f"/api/file/{VAULT}/csv/save",
|
|
params={"path": "fr.csv"},
|
|
json={"cells": {"B2": "Lyon"}},
|
|
)
|
|
assert resp.status_code == 200
|
|
text = _vault_file(client, "fr.csv").read_text(encoding="utf-8")
|
|
assert "Nom;Ville" in text
|
|
assert "Ada;Lyon" in text
|
|
assert "," not in text # the source delimiter is not switched to a comma
|
|
|
|
def test_quoted_semicolon_survives_a_round_trip(self, client, formats_vault):
|
|
(formats_vault / "quote-fr.csv").write_text(
|
|
'Nom;Note\n"Dupont; Jean";ok\n', encoding="utf-8"
|
|
)
|
|
client.put(
|
|
f"/api/file/{VAULT}/csv/save",
|
|
params={"path": "quote-fr.csv"},
|
|
json={"cells": {"B2": "modifié"}},
|
|
)
|
|
text = _vault_file(client, "quote-fr.csv").read_text(encoding="utf-8")
|
|
assert '"Dupont; Jean"' in text
|
|
assert ";modifié" 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
|