"""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": [], })