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