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
420 lines
15 KiB
Python
420 lines
15 KiB
Python
"""Unit tests for the existing-workbook AI tools (#153 A6).
|
|
|
|
Covers ``list_xlsx_sheets``, ``xlsx_to_markdown``, ``update_xlsx_cells`` and
|
|
``append_xlsx_rows`` — risk levels, confirmation gating, vault persistence and
|
|
the reuse of the guarded mutation service (P0 guards, formula neutralisation).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from backend.tools.api import (
|
|
ToolConfirmationRequired,
|
|
ToolContext,
|
|
ToolError,
|
|
call_tool,
|
|
get_tool,
|
|
)
|
|
from backend.tools.context import ToolRisk
|
|
|
|
openpyxl = pytest.importorskip("openpyxl")
|
|
|
|
|
|
@pytest.fixture
|
|
def vault(tmp_path, monkeypatch):
|
|
"""A minimal configured vault (index entry patched, no full build)."""
|
|
vault_dir = tmp_path / "Vault"
|
|
vault_dir.mkdir()
|
|
monkeypatch.setitem(
|
|
__import__("backend.indexer", fromlist=["index"]).index,
|
|
"Vault",
|
|
{"name": "Vault", "path": str(vault_dir), "config": {}},
|
|
)
|
|
return vault_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def workbook(vault: Path) -> Path:
|
|
"""A two-sheet workbook: Budget (values + a formula) and Notes."""
|
|
path = vault / "classeur.xlsx"
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "Budget"
|
|
ws.append(["Poste", "Montant"])
|
|
ws.append(["Loyer", 900])
|
|
ws.append(["Courses", 250])
|
|
notes = wb.create_sheet("Notes")
|
|
notes["A1"] = "bonjour"
|
|
wb.save(path)
|
|
return path
|
|
|
|
|
|
def _ctx() -> ToolContext:
|
|
return ToolContext(
|
|
user={"username": "tester", "role": "admin", "vaults": ["*"]},
|
|
audit_enabled=False,
|
|
)
|
|
|
|
|
|
class TestRegistry:
|
|
def test_read_tools_are_read_risk(self):
|
|
for name in ("list_xlsx_sheets", "xlsx_to_markdown"):
|
|
spec = get_tool(name)
|
|
assert spec is not None
|
|
assert spec.risk == ToolRisk.READ
|
|
|
|
def test_mutation_tools_require_confirmation(self):
|
|
for name in ("update_xlsx_cells", "append_xlsx_rows"):
|
|
spec = get_tool(name)
|
|
assert spec is not None
|
|
assert spec.risk == ToolRisk.WRITE
|
|
assert spec.requires_confirmation is True
|
|
|
|
def test_mutation_tools_raise_without_confirmation(self, vault, workbook):
|
|
with pytest.raises(ToolConfirmationRequired):
|
|
call_tool("update_xlsx_cells", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx",
|
|
"sheet": "Budget", "cells": {"B3": "300"},
|
|
})
|
|
with pytest.raises(ToolConfirmationRequired):
|
|
call_tool("append_xlsx_rows", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx",
|
|
"sheet": "Budget", "rows": [["Total", 1150]],
|
|
})
|
|
|
|
|
|
class TestListXlsxSheets:
|
|
def test_lists_names_and_dimensions(self, vault, workbook):
|
|
out = call_tool("list_xlsx_sheets", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx",
|
|
})
|
|
assert out.ok
|
|
data = out.data
|
|
assert [s["name"] for s in data["sheets"]] == ["Budget", "Notes"]
|
|
budget = data["sheets"][0]
|
|
assert budget["total_rows"] == 3 and budget["total_cols"] == 2
|
|
assert budget["truncated"] is False
|
|
|
|
def test_wrong_extension_rejected(self, vault):
|
|
with pytest.raises(ToolError):
|
|
call_tool("list_xlsx_sheets", _ctx(), {
|
|
"vault": "Vault", "path": "note.md",
|
|
})
|
|
|
|
def test_missing_file_rejected(self, vault):
|
|
with pytest.raises(ToolError):
|
|
call_tool("list_xlsx_sheets", _ctx(), {
|
|
"vault": "Vault", "path": "absent.xlsx",
|
|
})
|
|
|
|
|
|
class TestXlsxToMarkdown:
|
|
def test_renders_a_bounded_markdown_table(self, vault, workbook):
|
|
out = call_tool("xlsx_to_markdown", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx",
|
|
})
|
|
assert out.ok
|
|
data = out.data
|
|
assert data["sheet"] == "Budget"
|
|
assert data["rows"] == 3 and data["truncated"] is False
|
|
assert "| Poste | Montant |" in data["markdown"]
|
|
assert "| Loyer | 900 |" in data["markdown"]
|
|
|
|
def test_specific_sheet(self, vault, workbook):
|
|
out = call_tool("xlsx_to_markdown", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx", "sheet": "Notes",
|
|
})
|
|
assert out.ok
|
|
data = out.data
|
|
assert data["sheet"] == "Notes"
|
|
assert "bonjour" in data["markdown"]
|
|
|
|
def test_unknown_sheet_rejected(self, vault, workbook):
|
|
with pytest.raises(ToolError):
|
|
call_tool("xlsx_to_markdown", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx", "sheet": "Nope",
|
|
})
|
|
|
|
def test_big_sheet_is_flagged_truncated(self, vault):
|
|
path = vault / "gros.xlsx"
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
for i in range(150):
|
|
ws.append([f"r{i}", i])
|
|
wb.save(path)
|
|
out = call_tool("xlsx_to_markdown", _ctx(), {
|
|
"vault": "Vault", "path": "gros.xlsx",
|
|
})
|
|
assert out.ok
|
|
data = out.data
|
|
assert data["rows"] == 100
|
|
assert data["truncated"] is True
|
|
|
|
|
|
class TestUpdateXlsxCells:
|
|
def test_edits_cells_and_survives_a_reload(self, vault, workbook):
|
|
out = call_tool("update_xlsx_cells", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx",
|
|
"sheet": "Budget", "cells": {"B3": "300"},
|
|
}, confirm=True)
|
|
assert out.ok and out.data["status"] == "ok"
|
|
wb = openpyxl.load_workbook(workbook)
|
|
assert wb["Budget"]["B3"].value == 300 # coerced like the viewer
|
|
wb.close()
|
|
|
|
def test_formula_stays_text_by_default(self, vault, workbook):
|
|
out = call_tool("update_xlsx_cells", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx",
|
|
"sheet": "Budget", "cells": {"C1": "=B2+B3"},
|
|
}, confirm=True)
|
|
assert out.ok
|
|
wb = openpyxl.load_workbook(workbook)
|
|
assert wb["Budget"]["C1"].data_type == "s" # A4 guard inherited
|
|
wb.close()
|
|
|
|
def test_empty_cells_rejected(self, vault, workbook):
|
|
with pytest.raises(ToolError):
|
|
call_tool("update_xlsx_cells", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx",
|
|
"sheet": "Budget", "cells": {},
|
|
})
|
|
|
|
|
|
class TestAppendXlsxRows:
|
|
def test_appends_below_the_last_row(self, vault, workbook):
|
|
out = call_tool("append_xlsx_rows", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx",
|
|
"sheet": "Budget", "rows": [["Total", 1150]],
|
|
}, confirm=True)
|
|
assert out.ok
|
|
assert out.data["first_row"] == 4
|
|
wb = openpyxl.load_workbook(workbook)
|
|
ws = wb["Budget"]
|
|
assert ws["A4"].value == "Total"
|
|
assert ws["B4"].value == 1150 and isinstance(ws["B4"].value, int)
|
|
wb.close()
|
|
|
|
def test_values_are_coerced(self, vault, workbook):
|
|
out = call_tool("append_xlsx_rows", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx",
|
|
"sheet": "Budget", "rows": [["VRAI", "01/02/2026", 12.5]],
|
|
}, confirm=True)
|
|
assert out.ok
|
|
wb = openpyxl.load_workbook(workbook)
|
|
ws = wb["Budget"]
|
|
assert ws["A4"].value is True
|
|
assert (ws["B4"].value.month, ws["B4"].value.day) == (2, 1)
|
|
assert ws["C4"].value == 12.5
|
|
wb.close()
|
|
|
|
def test_unknown_sheet_rejected(self, vault, workbook):
|
|
with pytest.raises(ToolError):
|
|
call_tool("append_xlsx_rows", _ctx(), {
|
|
"vault": "Vault", "path": "classeur.xlsx",
|
|
"sheet": "Nope", "rows": [["x"]],
|
|
})
|
|
|
|
def test_empty_rows_rejected(self, vault, workbook):
|
|
with pytest.raises(ToolError):
|
|
call_tool("append_xlsx_rows", _ctx(), {
|
|
"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": [],
|
|
})
|