Files
ObsiGate/tests/test_xlsx_viewer.py
T
bruno 8611416670 feat: #152 viewer XLSX — affichage multi-feuilles, édition des cellules et téléchargement des .xlsx
- backend/xlsx_reader.py : rend openpyxl en tableaux HTML (plafond 500x40 par feuille)
- PUT /api/file/{vault}/xlsx/save : service edit_xlsx_cells (backup avant écriture, refs A1 validées)
- frontend : renderXlsxViewer (onglets, contenteditable, sauvegarde par feuille)
- docs : CHANGELOG [Unreleased], Roadmap index #152, archive, README FR/EN, exemple OpenAPI
2026-09-25 20:30:45 -04:00

153 lines
5.7 KiB
Python

"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save)."""
from __future__ import annotations
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
VAULT = "TestVault"
@pytest.fixture
def xlsx_file(test_vault_dir: str) -> str:
from openpyxl import Workbook
path = Path(test_vault_dir) / "budget.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Budget"
ws["A1"] = "Poste"
ws["B1"] = 100
ws["A2"] = "Total"
ws["B2"] = "=B1*2"
notes = wb.create_sheet("Notes")
notes["A1"] = "hello"
wb.save(path)
return str(path)
# ── Display ───────────────────────────────────────────────────────────────
class TestXlsxDisplay:
def test_renders_all_sheets(self, client, xlsx_file):
resp = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"})
assert resp.status_code == 200
data = resp.json()
assert data["is_xlsx"] is True
assert data["unsupported"] is False
assert [s["name"] for s in data["xlsx_sheets"]] == ["Budget", "Notes"]
first = data["xlsx_sheets"][0]["html"]
assert "Poste" in first
assert 'data-cell="B1"' in first
assert "=B1*2" in first # formula kept as text (data_only=False)
assert 'data-cell="A1"' in data["xlsx_sheets"][1]["html"]
def test_corrupt_xlsx_returns_500(self, client, test_vault_dir):
bad = Path(test_vault_dir) / "corrupt.xlsx"
bad.write_bytes(b"this is not a zip archive")
resp = client.get(f"/api/file/{VAULT}", params={"path": "corrupt.xlsx"})
assert resp.status_code == 500
# ── Index parity (tree visibility) ────────────────────────────────────────
class TestXlsxIndexing:
def test_xlsx_in_supported_extensions(self):
from backend.indexer import SUPPORTED_EXTENSIONS
assert ".xlsx" in SUPPORTED_EXTENSIONS
def test_xlsx_indexed_metadata_only(self, test_vault_dir, xlsx_file):
from backend.indexer import _index_single_file_sync
info = _index_single_file_sync(VAULT, test_vault_dir, xlsx_file)
assert info is not None
assert info["extension"] == ".xlsx"
assert info["content"] == "" # binary: never read into TF-IDF
assert info["title"] # filename-derived title
# ── Edit ──────────────────────────────────────────────────────────────────
class TestXlsxSave:
def _save(self, client, body, path="budget.xlsx"):
return client.put(
f"/api/file/{VAULT}/xlsx/save",
params={"path": path},
json=body,
)
def test_save_updates_cell_with_number_coercion(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"B1": "250"}})
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
assert wb["Budget"]["B1"].value == 250 # int, not "250"
def test_save_leaves_other_sheets_and_formulas(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "Titre", "B1": 42}})
assert resp.status_code == 200
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
assert wb["Budget"]["B2"].value == "=B1*2"
assert wb["Notes"]["A1"].value == "hello"
def test_save_empty_string_clears_cell(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": ""}})
assert resp.status_code == 200
from openpyxl import load_workbook
assert load_workbook(xlsx_file)["Budget"]["A1"].value is None
def test_save_creates_backup(self, client, xlsx_file):
from backend.services.backups import get_backup_dir
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "backup-me"}})
assert resp.status_code == 200
backup_dir = Path(get_backup_dir(VAULT, "budget.xlsx"))
assert backup_dir.is_dir()
assert list(backup_dir.glob("*.bak"))
def test_unknown_sheet_400(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Nope", "cells": {"A1": "x"}})
assert resp.status_code == 400
def test_invalid_cell_ref_400(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A-1": "x"}})
assert resp.status_code == 400
def test_wrong_extension_400(self, client, test_vault_dir):
(Path(test_vault_dir) / "note.md").write_text("# hi\n", encoding="utf-8")
resp = self._save(client, {"sheet": "Sheet", "cells": {"A1": "x"}}, path="note.md")
assert resp.status_code == 400
def test_missing_file_404(self, client):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "x"}}, path="absent.xlsx")
assert resp.status_code == 404
def test_too_many_cells_400(self, client, xlsx_file):
cells = {f"A{i}": i for i in range(1, 502)}
resp = self._save(client, {"sheet": "Budget", "cells": cells})
assert resp.status_code == 400
def test_nested_value_400(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": {"nested": 1}}})
assert resp.status_code == 400
def test_missing_sheet_field_400(self, client, xlsx_file):
resp = self._save(client, {"cells": {"A1": "x"}})
assert resp.status_code == 400