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:
@@ -223,3 +223,197 @@ class TestAppendXlsxRows:
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "rows": [],
|
||||
})
|
||||
|
||||
|
||||
# ── #156-A14 — formats étendus, recherche, analyse de plage, structure ──────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notes_csv(vault: Path) -> Path:
|
||||
"""A `;`-separated CSV — the locale the viewer sniffs (BUG-098)."""
|
||||
path = vault / "notes.csv"
|
||||
path.write_text("Poste;Montant\nLoyer;900\nCourses;250\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class TestRegistryExtended:
|
||||
def test_read_tools_include_search_and_analyze(self):
|
||||
for name in ("search_workbook", "analyze_range"):
|
||||
spec = get_tool(name)
|
||||
assert spec is not None
|
||||
assert spec.risk == ToolRisk.READ
|
||||
|
||||
def test_structure_tool_requires_confirmation(self, vault, workbook):
|
||||
spec = get_tool("edit_xlsx_structure")
|
||||
assert spec is not None
|
||||
assert spec.risk == ToolRisk.WRITE
|
||||
with pytest.raises(ToolConfirmationRequired):
|
||||
call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}],
|
||||
})
|
||||
|
||||
|
||||
class TestXlsmSupport:
|
||||
def test_reads_and_edits_an_xlsm(self, vault):
|
||||
path = vault / "macro.xlsm"
|
||||
wb = openpyxl.Workbook()
|
||||
wb.active.title = "Donnees"
|
||||
wb.active.append(["A", "B"])
|
||||
wb.active.append([1, 2])
|
||||
wb.save(path)
|
||||
|
||||
out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "macro.xlsm"})
|
||||
assert out.ok and out.data["sheets"][0]["name"] == "Donnees"
|
||||
md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "macro.xlsm"})
|
||||
assert "| A | B |" in md.data["markdown"]
|
||||
|
||||
edit = call_tool("update_xlsx_cells", _ctx(), {
|
||||
"vault": "Vault", "path": "macro.xlsm", "sheet": "Donnees",
|
||||
"cells": {"A2": "9"},
|
||||
}, confirm=True)
|
||||
assert edit.ok
|
||||
wb2 = openpyxl.load_workbook(path)
|
||||
assert wb2["Donnees"]["A2"].value == 9
|
||||
wb2.close()
|
||||
|
||||
|
||||
class TestCsvSupport:
|
||||
def test_lists_reads_and_searches_a_csv(self, vault, notes_csv):
|
||||
out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "notes.csv"})
|
||||
assert out.ok
|
||||
assert out.data["sheets"] == [
|
||||
{"name": "notes", "total_rows": 3, "total_cols": 2, "truncated": False}
|
||||
]
|
||||
md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "notes.csv"})
|
||||
assert "| Poste | Montant |" in md.data["markdown"]
|
||||
found = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv", "query": "Loyer",
|
||||
})
|
||||
assert found.ok
|
||||
assert found.data["total"] == 1
|
||||
assert found.data["matches"][0] == {"sheet": "notes", "cell": "A2", "value": "Loyer"}
|
||||
assert found.data["by_sheet"] == {"notes": 1}
|
||||
|
||||
def test_updates_a_csv_keeping_its_delimiter(self, vault, notes_csv):
|
||||
out = call_tool("update_xlsx_cells", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv", "cells": {"B2": "1000"},
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
assert "Loyer;1000" in notes_csv.read_text(encoding="utf-8-sig")
|
||||
|
||||
def test_append_rows_refuses_a_csv(self, vault, notes_csv):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("append_xlsx_rows", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv",
|
||||
"sheet": "notes", "rows": [["x"]],
|
||||
}, confirm=True)
|
||||
|
||||
def test_structure_refuses_a_csv(self, vault, notes_csv):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv",
|
||||
"actions": [{"op": "sheet_add", "name": "X"}],
|
||||
}, confirm=True)
|
||||
|
||||
def test_wrong_extension_is_still_refused(self, vault):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "note.md", "query": "a",
|
||||
})
|
||||
|
||||
|
||||
class TestSearchWorkbook:
|
||||
def test_scans_every_sheet_with_counts(self, vault, workbook):
|
||||
out = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "o",
|
||||
})
|
||||
assert out.ok
|
||||
assert out.data["by_sheet"]["Budget"] > 0
|
||||
assert out.data["by_sheet"]["Notes"] == 1 # « bonjour » : une seule cellule
|
||||
assert out.data["truncated"] is False
|
||||
assert out.data["total"] == sum(out.data["by_sheet"].values())
|
||||
|
||||
def test_case_sensitive_and_sheet_scope(self, vault, workbook):
|
||||
loose = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "LOYER",
|
||||
})
|
||||
assert loose.data["total"] == 1
|
||||
strict = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "LOYER",
|
||||
"case_sensitive": True,
|
||||
})
|
||||
assert strict.data["total"] == 0
|
||||
scoped = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "bonjour",
|
||||
"sheet": "Budget",
|
||||
})
|
||||
assert scoped.data["total"] == 0
|
||||
|
||||
def test_empty_query_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": " ",
|
||||
})
|
||||
|
||||
|
||||
class TestAnalyzeRange:
|
||||
def test_aggregates_a_column(self, vault, workbook):
|
||||
out = call_tool("analyze_range", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "range": "B2:B3",
|
||||
})
|
||||
assert out.ok
|
||||
stats = out.data["numeric"]
|
||||
assert stats["count"] == 2
|
||||
assert stats["sum"] == 1150
|
||||
assert stats["mean"] == 575
|
||||
assert stats["min"] == 250 and stats["max"] == 900
|
||||
|
||||
def test_whole_sheet_and_fr_separators(self, vault):
|
||||
path = vault / "prix.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["Article", "Prix"])
|
||||
ws.append(["A", "1 234,5"])
|
||||
ws.append(["B", 10])
|
||||
wb.save(path)
|
||||
out = call_tool("analyze_range", _ctx(), {"vault": "Vault", "path": "prix.xlsx"})
|
||||
assert out.ok
|
||||
assert out.data["numeric"]["count"] == 2
|
||||
assert out.data["numeric"]["max"] == 1234.5
|
||||
assert out.data["cells"] == 6 # 2 en-têtes + 4 valeurs
|
||||
|
||||
def test_bad_range_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("analyze_range", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "range": "pas-une-plage",
|
||||
})
|
||||
|
||||
|
||||
class TestEditXlsxStructure:
|
||||
def test_inserts_a_row_and_survives_a_reload(self, vault, workbook):
|
||||
out = call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}],
|
||||
}, confirm=True)
|
||||
assert out.ok and out.data["actions"] == 1
|
||||
wb = openpyxl.load_workbook(workbook)
|
||||
assert wb["Budget"]["A3"].value == "Loyer" # la ligne 2 a été poussée
|
||||
wb.close()
|
||||
|
||||
def test_adds_a_sheet(self, vault, workbook):
|
||||
out = call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"actions": [{"op": "sheet_add", "name": "Trois"}],
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
wb = openpyxl.load_workbook(workbook)
|
||||
assert "Trois" in wb.sheetnames
|
||||
wb.close()
|
||||
|
||||
def test_empty_actions_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "actions": [],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user