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
+219
View File
@@ -474,6 +474,64 @@ class TestXlsxLossyGuard:
assert inspect_workbook(bad) == []
# ── BUG-099 — the cached-value probe must never fail silently ──────────────
def _probe_zip(tmp_path: Path, sheets: list[bytes]) -> Path:
"""Build a minimal archive holding the given worksheet XML parts."""
path = tmp_path / "probe.xlsx"
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
for i, blob in enumerate(sheets, start=1):
zf.writestr(f"xl/worksheets/sheet{i}.xml", blob)
return path
class TestXlsxCachedValueProbe:
"""BUG-099 — an exhausted probe is reported, never mistaken for clean."""
def test_exhausted_budget_is_reported_as_unverified(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from backend import xlsx_reader
# Budget smaller than the only sheet: the scan cannot reach its end.
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 64)
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 64)
book = _probe_zip(tmp_path, [b"<x/>" + b"filler" * 1024])
# Contre-preuve: the old code returned [] here — an interrupted scan was
# indistinguishable from a completed one, so the write guard stayed
# silent and the cached values could be dropped without warning.
assert xlsx_reader.inspect_workbook(book) == ["cached_values_unverified"]
def test_a_huge_first_sheet_does_not_starve_the_next_one(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from backend import xlsx_reader
# Per-sheet cap (1 KiB) well under the total allowance (4 KiB): a single
# shared budget would spend everything on sheet 1 and never look at
# sheet 2, where the only cached formula lives.
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 1024)
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 4096)
cached = b'<sheetData><c r="A1"><f>1+1</f><v>2</v></c></sheetData>'
book = _probe_zip(tmp_path, [b"filler" * 2048, cached])
assert "cached_values" in xlsx_reader.inspect_workbook(book)
def test_a_completed_clean_scan_reports_nothing(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from backend import xlsx_reader
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 4096)
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 8192)
book = _probe_zip(tmp_path, [b"<x/>", b"<x/>"])
# No cached value and no budget issue: no false positive either.
assert xlsx_reader.inspect_workbook(book) == []
# ── #153 A2 — atomic write ───────────────────────────────────────────────
@@ -741,3 +799,164 @@ class TestXlsxFormulaGuard:
ws = openpyxl.load_workbook(xlsx_file)["Budget"]
assert ws["B3"].value == 42 and isinstance(ws["B3"].value, int)
assert ws["B4"].value == -3.5
class TestMetaCache:
"""#156-A13 — ``read_workbook_meta`` is cached on (path, mtime_ns, size).
A truncated sheet is fetched window by window: without the cache every
window paid a full normal-mode workbook load just for the style / merge /
freeze maps of one sheet.
"""
def test_second_call_is_served_from_the_cache(self, xlsx_file, monkeypatch):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
real_load = xlsx_reader.load_workbook
loads = []
def counting(*args, **kwargs):
loads.append(1)
return real_load(*args, **kwargs)
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
first = xlsx_reader.read_workbook_meta(path)
assert len(loads) == 1, "la première lecture charge le classeur"
second = xlsx_reader.read_workbook_meta(path)
assert len(loads) == 1, "la seconde lecture ne recharge pas le classeur"
assert second is first, "la même carte est servie (lecture seule côté appelant)"
assert "Budget" in first
def test_invalidation_forces_a_reload(self, xlsx_file, monkeypatch):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
real_load = xlsx_reader.load_workbook
loads = []
def counting(*args, **kwargs):
loads.append(1)
return real_load(*args, **kwargs)
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
xlsx_reader.read_workbook_meta(path)
xlsx_reader.invalidate_workbook_meta(path)
xlsx_reader.read_workbook_meta(path)
assert len(loads) == 2, "l'invalidation explicite force un rechargement"
def test_rewriting_the_file_invalidates_the_cache(self, xlsx_file):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == []
wb = openpyxl.load_workbook(xlsx_file)
wb["Budget"].merge_cells("A1:B1")
wb.save(xlsx_file)
wb.close()
# (mtime_ns, size) changed: the cached maps cannot be served any more.
assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == ["A1:B1"]
def test_two_windows_share_one_metadata_load(self, xlsx_file, monkeypatch):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
real_load = xlsx_reader.load_workbook
normal = []
def counting(*args, **kwargs):
if not kwargs.get("read_only"):
normal.append(1)
return real_load(*args, **kwargs)
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
assert xlsx_reader.read_sheet_window(path, "Budget", 0, 1) is not None
assert xlsx_reader.read_sheet_window(path, "Budget", 1, 1) is not None
assert len(normal) == 1, "les deux fenêtres partagent une seule lecture de métadonnées"
class TestOptimisticConcurrency:
"""#156-A12 — the read exposes a revision, a write may require it.
A stale ``if_match`` (the file changed since it was read: Excel, the
watcher, another worker) is refused with 409 ``conflict`` instead of
silently overwriting the other writer.
"""
def _save(self, client, body, path="budget.xlsx"):
return client.put(
f"/api/file/{VAULT}/xlsx/save", params={"path": path}, json=body
)
def _revision(self, client, path="budget.xlsx"):
resp = client.get(f"/api/file/{VAULT}", params={"path": path})
assert resp.status_code == 200
return resp.json()["xlsx_revision"]
def test_the_read_exposes_a_revision_and_the_write_refreshes_it(self, client, xlsx_file):
rev = self._revision(client)
assert isinstance(rev, str) and rev
resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev})
assert resp.status_code == 200
assert resp.json()["revision"] and resp.json()["revision"] != rev
def test_a_stale_if_match_is_refused_and_writes_nothing(self, client, xlsx_file):
rev = self._revision(client)
assert self._save(
client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev}
).status_code == 200
# The token is stale now: the client must re-read before writing again.
resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "deux"}, "if_match": rev})
assert resp.status_code == 409
assert resp.json()["code"] == "conflict"
assert resp.json()["details"]["reason"] == "stale_revision"
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value is None
def test_omitting_if_match_keeps_last_writer_wins(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "sans-jeton"}})
assert resp.status_code == 200
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value == "sans-jeton"
def test_an_invalid_if_match_is_rejected(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "x"}, "if_match": 42})
assert resp.status_code == 400
def test_the_structure_endpoint_honours_if_match(self, client, xlsx_file):
rev = self._revision(client)
actions = [{"op": "row_insert", "sheet": "Budget", "at": 1}]
ok = client.put(
f"/api/file/{VAULT}/xlsx/structure",
params={"path": "budget.xlsx"},
json={"actions": actions, "if_match": rev},
)
assert ok.status_code == 200
stale = client.put(
f"/api/file/{VAULT}/xlsx/structure",
params={"path": "budget.xlsx"},
json={"actions": actions, "if_match": rev},
)
assert stale.status_code == 409
assert stale.json()["code"] == "conflict"
def test_the_csv_endpoint_honours_if_match(self, client, test_vault_dir):
csv_path = Path(test_vault_dir) / "notes.csv"
csv_path.write_text("a,b\n1,2\n", encoding="utf-8")
rev = self._revision(client, "notes.csv")
ok = client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "notes.csv"},
json={"cells": {"A1": "x"}, "if_match": rev},
)
assert ok.status_code == 200
assert ok.json()["revision"] != rev
stale = client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "notes.csv"},
json={"cells": {"A1": "y"}, "if_match": rev},
)
assert stale.status_code == 409
assert csv_path.read_text(encoding="utf-8").startswith("x")