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
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""Style metadata in the .xlsx viewer (#153 A15): bold, colors, number formats,
|
|
merged ranges and freeze panes — plus the endpoint wiring."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
openpyxl = pytest.importorskip("openpyxl")
|
|
|
|
VAULT = "TestVault"
|
|
|
|
|
|
@pytest.fixture
|
|
def styled_book(test_vault_dir: str) -> str:
|
|
"""A workbook with a bold red-font header, a filled cell, a merge and a freeze."""
|
|
from openpyxl.styles import Font, PatternFill
|
|
|
|
path = Path(test_vault_dir) / "styles.xlsx"
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "Data"
|
|
ws["A1"] = "En-tête"
|
|
ws["A1"].font = Font(bold=True, italic=True, color="FFCC0000")
|
|
ws["B1"] = "Total"
|
|
ws["B1"].fill = PatternFill("solid", fgColor="FFFFEE99")
|
|
ws["B2"] = 1234.5
|
|
ws["B2"].number_format = "#,##0.00"
|
|
ws["C3"] = "fusionnée"
|
|
ws.merge_cells("C3:D3")
|
|
ws["C3"].value = "fusionnée"
|
|
ws.freeze_panes = "A2"
|
|
wb.save(path)
|
|
return str(path)
|
|
|
|
|
|
@pytest.fixture
|
|
def plain_book(test_vault_dir: str) -> str:
|
|
"""An unstyled workbook — the write side starts from a blank slate."""
|
|
path = Path(test_vault_dir) / "plain.xlsx"
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "Data"
|
|
ws.append(["Nom", "Valeur"])
|
|
ws.append(["a", 1])
|
|
ws.append(["b", 2])
|
|
wb.save(path)
|
|
return str(path)
|
|
|
|
|
|
class TestStyleReading:
|
|
def test_bold_italic_and_font_color(self, styled_book):
|
|
from backend.xlsx_reader import read_sheet_styles
|
|
|
|
styles = read_sheet_styles(Path(styled_book), "Data")
|
|
a1 = styles["A1"]
|
|
assert "font-weight:600" in a1["style"]
|
|
assert "font-style:italic" in a1["style"]
|
|
assert a1["style"].startswith("color:#cc0000")
|
|
|
|
def test_background_fill_is_read(self, styled_book):
|
|
from backend.xlsx_reader import read_sheet_styles
|
|
|
|
styles = read_sheet_styles(Path(styled_book), "Data")
|
|
assert "background:#ffee99" in styles["B1"]["style"]
|
|
|
|
def test_number_format_is_signalled(self, styled_book):
|
|
from backend.xlsx_reader import read_sheet_styles
|
|
|
|
styles = read_sheet_styles(Path(styled_book), "Data")
|
|
assert "font-family" in styles["B2"]["style"]
|
|
|
|
def test_plain_cell_has_no_entry(self, styled_book):
|
|
from backend.xlsx_reader import read_sheet_styles
|
|
|
|
styles = read_sheet_styles(Path(styled_book), "Data")
|
|
assert "A2" not in styles # untouched cell
|
|
|
|
def test_merges_and_freeze(self, styled_book):
|
|
from backend.xlsx_reader import read_sheet_freeze, read_sheet_merges
|
|
|
|
assert read_sheet_merges(Path(styled_book), "Data") == ["C3:D3"]
|
|
assert read_sheet_freeze(Path(styled_book), "Data") == "A2"
|
|
|
|
def test_unknown_sheet_yields_empty(self, styled_book):
|
|
from backend.xlsx_reader import read_sheet_styles
|
|
|
|
assert read_sheet_styles(Path(styled_book), "Nope") == {}
|
|
|
|
def test_broken_file_yields_empty(self, test_vault_dir):
|
|
from backend.xlsx_reader import read_sheet_styles
|
|
|
|
bad = Path(test_vault_dir) / "broken-styles.xlsx"
|
|
bad.write_bytes(b"not a zip")
|
|
assert read_sheet_styles(bad, "Data") == {}
|
|
|
|
|
|
class TestStyleWriting:
|
|
"""#156-A8 — the viewer can now WRITE formatting, not just read it.
|
|
|
|
One locked, atomic rewrite through ``PUT …/xlsx/style``: styles (bold,
|
|
italic, underline, colours, alignment, number format), merges, column
|
|
widths, row heights and frozen panes.
|
|
"""
|
|
|
|
def _style(self, client, path, ops, **extra):
|
|
return client.put(
|
|
f"/api/file/{VAULT}/xlsx/style",
|
|
params={"path": path},
|
|
json={"ops": ops, **extra},
|
|
)
|
|
|
|
def test_styles_survive_a_reload(self, client, plain_book):
|
|
resp = self._style(client, "plain.xlsx", [
|
|
{
|
|
"op": "cell", "sheet": "Data", "range": "A1:B1",
|
|
"style": {
|
|
"bold": True, "italic": True, "underline": True,
|
|
"font_color": "#cc0000", "fill_color": "#ffe08a",
|
|
"align": "center", "number_format": "0.00",
|
|
},
|
|
},
|
|
])
|
|
assert resp.status_code == 200
|
|
assert resp.json()["revision"]
|
|
wb = openpyxl.load_workbook(plain_book)
|
|
ws = wb["Data"]
|
|
assert ws["A1"].font.bold is True and ws["A1"].font.italic is True
|
|
assert ws["A1"].font.underline == "single"
|
|
assert ws["A1"].font.color.rgb == "FFCC0000"
|
|
assert ws["A1"].fill.start_color.rgb == "FFFFE08A"
|
|
assert ws["A1"].alignment.horizontal == "center"
|
|
assert ws["B1"].number_format == "0.00"
|
|
assert ws["A1"].value == "Nom", "the value is untouched"
|
|
assert not ws["A2"].font.bold, "a neighbour outside the range is untouched"
|
|
wb.close()
|
|
|
|
def test_the_written_style_is_read_back_by_the_viewer(self, client, plain_book):
|
|
self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "fill_color": "#ffe08a"}},
|
|
])
|
|
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
|
assert resp.status_code == 200
|
|
sheet = resp.json()["xlsx_sheets"][0]
|
|
style = sheet["styles"]["A1"]
|
|
assert "font-weight:600" in style and "background:#ffe08a" in style
|
|
|
|
def test_merges_sizes_and_freeze(self, client, plain_book):
|
|
resp = self._style(client, "plain.xlsx", [
|
|
{"op": "merge", "sheet": "Data", "range": "A1:B1"},
|
|
{"op": "col_width", "sheet": "Data", "col": "A", "width": 24},
|
|
{"op": "row_height", "sheet": "Data", "row": 2, "height": 30},
|
|
{"op": "freeze", "sheet": "Data", "cell": "A2"},
|
|
])
|
|
assert resp.status_code == 200
|
|
wb = openpyxl.load_workbook(plain_book)
|
|
ws = wb["Data"]
|
|
assert [str(r) for r in ws.merged_cells.ranges] == ["A1:B1"]
|
|
assert ws.column_dimensions["A"].width == 24
|
|
assert ws.row_dimensions[2].height == 30
|
|
assert str(ws.freeze_panes) == "A2"
|
|
wb.close()
|
|
|
|
resp = self._style(client, "plain.xlsx", [
|
|
{"op": "unmerge", "sheet": "Data", "range": "A1:B1"},
|
|
{"op": "freeze", "sheet": "Data", "cell": ""},
|
|
])
|
|
assert resp.status_code == 200
|
|
wb = openpyxl.load_workbook(plain_book)
|
|
ws = wb["Data"]
|
|
assert not list(ws.merged_cells.ranges)
|
|
assert ws.freeze_panes is None
|
|
wb.close()
|
|
|
|
def test_a_style_can_be_cleared(self, client, plain_book):
|
|
self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "font_color": "#cc0000", "fill_color": "#ffe08a"}},
|
|
])
|
|
resp = self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": False, "font_color": "", "fill_color": "", "number_format": "General"}},
|
|
])
|
|
assert resp.status_code == 200
|
|
wb = openpyxl.load_workbook(plain_book)
|
|
ws = wb["Data"]
|
|
assert ws["A1"].font.bold in (None, False)
|
|
assert ws["A1"].font.color is None
|
|
assert ws["A1"].fill.fill_type is None
|
|
assert ws["A1"].number_format == "General"
|
|
wb.close()
|
|
|
|
def test_invalid_inputs_are_refused(self, client, plain_book):
|
|
bad_style = self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"blink": True}},
|
|
])
|
|
assert bad_style.status_code == 400
|
|
assert self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A1", "style": {}},
|
|
]).status_code == 400
|
|
assert self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"fill_color": "rouge"}},
|
|
]).status_code == 400
|
|
assert self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "pas-une-plage", "style": {"bold": True}},
|
|
]).status_code == 400
|
|
assert self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Nope", "range": "A1", "style": {"bold": True}},
|
|
]).status_code == 400
|
|
assert self._style(client, "plain.xlsx", [{"op": "spin", "sheet": "Data"}]).status_code == 400
|
|
assert self._style(client, "plain.xlsx", []).status_code == 400
|
|
assert self._style(client, "plain.xlsx", [
|
|
{"op": "col_width", "sheet": "Data", "col": "A", "width": 900},
|
|
]).status_code == 400
|
|
assert self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"align": "diagonal"}},
|
|
]).status_code == 400
|
|
|
|
def test_a_csv_is_refused(self, client, test_vault_dir):
|
|
(Path(test_vault_dir) / "style.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
|
resp = self._style(client, "style.csv", [
|
|
{"op": "cell", "sheet": "x", "range": "A1", "style": {"bold": True}},
|
|
])
|
|
assert resp.status_code == 400
|
|
|
|
def test_a_stale_revision_is_refused_and_the_fresh_one_accepted(self, client, plain_book):
|
|
first = self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}},
|
|
])
|
|
assert first.status_code == 200
|
|
stale = self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}},
|
|
], if_match="deadbeef-1")
|
|
assert stale.status_code == 409
|
|
assert stale.json()["details"]["reason"] == "stale_revision"
|
|
fresh = self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}},
|
|
], if_match=first.json()["revision"])
|
|
assert fresh.status_code == 200
|
|
assert self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A3", "style": {"bold": True}},
|
|
], if_match=42).status_code == 400
|
|
|
|
def test_a_backup_is_written_before_the_rewrite(self, client, plain_book):
|
|
from backend.services.backups import get_backup_dir
|
|
|
|
assert self._style(client, "plain.xlsx", [
|
|
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}},
|
|
]).status_code == 200
|
|
backups = list(get_backup_dir(VAULT, "plain.xlsx").glob("plain.xlsx.*.bak"))
|
|
assert backups, "the rewrite is backed up first"
|
|
|
|
|
|
class TestEndpointWiring:
|
|
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
|
|
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
|
|
assert resp.status_code == 200
|
|
sheet = resp.json()["xlsx_sheets"][0]
|
|
assert sheet["styles"]["A1"].startswith("color:#cc0000")
|
|
assert sheet["aligns"] == {} or isinstance(sheet["aligns"], dict)
|
|
assert sheet["merges"] == ["C3:D3"]
|
|
assert sheet["freeze"] == "A2"
|
|
|
|
def test_rendered_html_carries_the_inline_style(self, client, styled_book):
|
|
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
|
|
html = resp.json()["xlsx_sheets"][0]["html"]
|
|
assert 'data-cell="A1" style="' in html
|
|
assert "font-weight:600" in html
|