- 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
153 lines
5.7 KiB
Python
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
|