feat: tableau de bord classeur - plages nommees, TCD/graphiques et KPI par feuille #153
A17 — nouveau endpoint GET /api/file/{vault}/xlsx/dashboard (read_workbook_
dashboard : plages nommees avec portee depuis defined_names read_only,
comptage graphiques/TCD par parts OPC, stats par feuille bornées 500x40 :
cellules/lignes/colonnes/formules/numerique + 8 premieres valeurs en cartes
KPI) et panneau frontend toggled depuis la toolbar (table des plages,
cartes KPI par feuille, hint actions IA). Bouton absent pour .csv et
formats en lecture seule ; le menu structure est saute quand le bouton
n'existe pas. i18n FR/EN (xlsx.dashboard_*), 8 tests backend + 2 tests
JSDOM + contre-preuve (5 echecs sur neutralisation), ruff/mypy 0.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
@@ -648,6 +648,50 @@ await test("the 409 lossy flow re-emits with force after confirmation", async ()
|
||||
assert.equal(calls[1].body.force, true);
|
||||
});
|
||||
|
||||
// ── A17 — dashboard panel ─────────────────────────────────────────────
|
||||
await test("the dashboard button fetches the metadata and renders named ranges + KPIs", async () => {
|
||||
const area = mount();
|
||||
const dashBtn = area.querySelector("#xlsx-dashboard-btn");
|
||||
assert.ok(dashBtn, "the dashboard button exists for an editable workbook");
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
vault: "V",
|
||||
path: "data.xlsx",
|
||||
named_ranges: [{ name: "MaPlage", scope: "", ref: "Data!$A$1:$B$5" }],
|
||||
objects: { charts: 2, pivots: 1 },
|
||||
sheets: [{ name: "Feuille1", cells: 9, rows: 3, cols: 3, formulas: 1, numeric: 2, kpi: [{ label: "A2", value: 12 }] }],
|
||||
},
|
||||
});
|
||||
dashBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.match(calls[0].url, /\/xlsx\/dashboard\?path=data\.xlsx/);
|
||||
const panel = area.querySelector(".xlsx-dashboard");
|
||||
assert.ok(panel, "the dashboard panel is rendered");
|
||||
assert.ok(panel.textContent.includes("MaPlage"), "named range is listed");
|
||||
assert.ok(panel.textContent.includes("A2"), "KPI label is rendered");
|
||||
assert.ok(panel.textContent.includes("12"), "KPI value is rendered");
|
||||
// Second click closes the panel.
|
||||
dashBtn.click();
|
||||
assert.equal(area.querySelector(".xlsx-dashboard"), null, "panel toggles closed");
|
||||
});
|
||||
|
||||
await test("a csv mounts without the dashboard button", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V",
|
||||
path: "data.csv",
|
||||
is_csv: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
assert.equal(area.querySelector("#xlsx-dashboard-btn"), null, "no dashboard for csv");
|
||||
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure menu for csv");
|
||||
assert.ok(area.querySelector("td[data-cell=\"A1\"]"), "the grid is still editable");
|
||||
});
|
||||
|
||||
await test("two windows in a row walk the whole sheet", async () => {
|
||||
const area = mountTruncated({ total: 1200 });
|
||||
apiQueue.push({
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Workbook dashboard: named ranges, chart/pivot objects and per-sheet KPI
|
||||
stats (#153 A17), plus the ``GET …/xlsx/dashboard`` endpoint wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
VAULT = "TestVault"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_book(test_vault_dir: str) -> str:
|
||||
"""A workbook with a named range, a chart and numeric KPI cells."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.chart import BarChart, Reference
|
||||
from openpyxl.workbook.defined_name import DefinedName
|
||||
|
||||
path = Path(test_vault_dir) / "dash.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "Ventes"
|
||||
ws["A2"] = 12
|
||||
ws["A3"] = 48
|
||||
ws["B2"] = "=SUM(A2:A3)"
|
||||
wb.defined_names.add(DefinedName("MaPlage", attr_text="Data!$A$1:$B$5"))
|
||||
chart = BarChart()
|
||||
chart.add_data(Reference(ws, min_col=1, min_row=1, max_row=3))
|
||||
ws.add_chart(chart, "D2")
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plain_book(test_vault_dir: str) -> str:
|
||||
"""A workbook without any dashboard-worthy feature."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "plain.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Vide"
|
||||
ws["A1"] = "texte seul"
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
class TestDashboardReading:
|
||||
def test_named_range_is_detected(self, dash_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(dash_book))
|
||||
names = [r["name"] for r in dash["named_ranges"]]
|
||||
assert "MaPlage" in names
|
||||
entry = next(r for r in dash["named_ranges"] if r["name"] == "MaPlage")
|
||||
assert "Data!" in entry["ref"]
|
||||
|
||||
def test_charts_are_counted(self, dash_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(dash_book))
|
||||
assert dash["objects"]["charts"] >= 1
|
||||
assert dash["objects"]["pivots"] == 0
|
||||
|
||||
def test_sheet_kpis_carry_numeric_cells(self, dash_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(dash_book))
|
||||
sheet = next(s for s in dash["sheets"] if s["name"] == "Data")
|
||||
assert sheet["cells"] >= 4
|
||||
assert sheet["formulas"] == 1
|
||||
assert sheet["numeric"] >= 2
|
||||
values = [k["value"] for k in sheet["kpi"]]
|
||||
assert 12 in values and 48 in values
|
||||
|
||||
def test_plain_book_yields_empty_dashboard(self, plain_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(plain_book))
|
||||
assert dash["named_ranges"] == []
|
||||
assert dash["objects"]["charts"] == 0
|
||||
sheet = dash["sheets"][0]
|
||||
assert sheet["numeric"] == 0
|
||||
assert sheet["kpi"] == []
|
||||
|
||||
def test_broken_book_yields_empty_payload(self, test_vault_dir):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
bad = Path(test_vault_dir) / "broken-dash.xlsx"
|
||||
bad.write_bytes(b"not a zip")
|
||||
dash = read_workbook_dashboard(bad)
|
||||
assert dash["named_ranges"] == []
|
||||
assert dash["sheets"] == []
|
||||
|
||||
|
||||
class TestDashboardEndpoint:
|
||||
def test_endpoint_serves_the_dashboard(self, client, dash_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": dash_book})
|
||||
assert resp.status_code == 200 # sanity: file is readable
|
||||
dash = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": dash_book}
|
||||
).json()
|
||||
assert any(r["name"] == "MaPlage" for r in dash["named_ranges"])
|
||||
assert dash["objects"]["charts"] >= 1
|
||||
assert any(s["kpi"] for s in dash["sheets"])
|
||||
|
||||
def test_unknown_file_is_404(self, client):
|
||||
resp = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "nope.xlsx"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_non_workbook_is_415(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "texte.txt").write_text("hello", encoding="utf-8")
|
||||
resp = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "texte.txt"}
|
||||
)
|
||||
assert resp.status_code == 415
|
||||
Reference in New Issue
Block a user