feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
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

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
This commit is contained in:
2026-09-30 07:04:47 -04:00
parent 1bacfd69d9
commit c5c225a68e
32 changed files with 4615 additions and 302 deletions
+76 -1
View File
@@ -95,9 +95,32 @@ class TestXlsmEditable:
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):
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"})
@@ -149,6 +172,22 @@ class TestLegacyReadOnly:
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")
@@ -197,6 +236,42 @@ class TestCsvEditable:
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(