Files
ObsiGate/tests/test_xlsx_formats.py
T
bruno c5c225a68e
CI / lint (push) Successful in 2m36s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 2m39s
CI / e2e (push) Successful in 15m45s
feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
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
2026-09-30 07:04:47 -04:00

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