feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
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
This commit is contained in:
@@ -35,6 +35,20 @@ def styled_book(test_vault_dir: str) -> str:
|
||||
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
|
||||
@@ -82,6 +96,160 @@ class TestStyleReading:
|
||||
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"})
|
||||
|
||||
Reference in New Issue
Block a user