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
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user