feat: tableurs indexables, saisie typée et valeurs calculées #153
Les tableurs étaient invisibles à la recherche, chaque saisie devenait du texte, et une formule n'affichait que sa formule. - A5 : extract_indexable_text() indexe les noms de feuilles et les 20 premières lignes (plafond 5 k caractères) pour le TF-IDF et la recherche sémantique. Un mot tapé dans une cellule rend le fichier trouvable ; un classeur chiffré s'indexe par son seul nom. - A10 : la saisie est typée comme dans Excel — booléens (TRUE/FAUX/OUI/NON) et dates FR JJ/MM/AAAA en ordre jour-first, donc 01/02/2026 est le 1er février. Une saisie ressemblant à une formule n'est jamais convertie (BUG-088 préservé). - A12 : la valeur calculée par Excel s'affiche sous la formule, via une 2e lecture data_only=True faite seulement si l'archive contient un <v>. Info-bulle traduite FR/EN, aucun texte d'interface côté backend. - BUG-089 : un reindex manuel ne reconstruisait pas l'index inversé, et backend/search.py lisait l'index via un import par valeur — après un rechargement du module, la recherche écrivait dans un dict périmé. Contre-preuves vérifiées pour A5, A10 et A12 (neutralisation de chaque fonction → échec des tests concernés). Tests : 1402 pytest, 10 JSDOM, 4 E2E, suite E2E complète verte, ruff/mypy 0. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
@@ -11,7 +11,8 @@
|
||||
* - the warning banner lists both features ;
|
||||
* - saving a cell on that workbook asks for confirmation (native dialog) and
|
||||
* then succeeds (the client retries with `force: true`) ;
|
||||
* - the f(x) toggle is off by default, so "=B1*3" is stored as text.
|
||||
* - the f(x) toggle is off by default, so "=B1*3" is stored as text ;
|
||||
* - the value Excel last computed is shown under the formula (#153 A12).
|
||||
*
|
||||
* The fixture is restored byte-for-byte in `afterAll` so a local run never
|
||||
* dirties the working copy.
|
||||
@@ -53,7 +54,7 @@ async function openFixture(page) {
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('Excel viewer — garde-fous d\'écriture (#153 P0)', () => {
|
||||
test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#153)', () => {
|
||||
test.beforeAll(() => {
|
||||
if (existsSync(FIXTURE_PATH)) originalBytes = readFileSync(FIXTURE_PATH);
|
||||
});
|
||||
@@ -62,6 +63,28 @@ test.describe('Excel viewer — garde-fous d\'écriture (#153 P0)', () => {
|
||||
if (originalBytes) writeFileSync(FIXTURE_PATH, originalBytes);
|
||||
});
|
||||
|
||||
// Read-only assertions come FIRST, before the mutating tests: saving through
|
||||
// the viewer rewrites the workbook and an openpyxl round-trip drops the cached
|
||||
// formula results (BUG-085), so the shadow line only exists on a pristine
|
||||
// fixture.
|
||||
test('affiche la valeur calculée en cache sous la formule (#153 A12)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>).
|
||||
const formulaCell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await expect(formulaCell).toContainText('=B1*2');
|
||||
|
||||
const cached = formulaCell.locator('.xlsx-cached');
|
||||
await expect(cached).toHaveCount(1);
|
||||
await expect(cached).toHaveText('200');
|
||||
// The tooltip is translated client-side, never hardcoded by the backend.
|
||||
await expect(cached).toHaveAttribute('title', /Excel/);
|
||||
|
||||
// A plain value cell must not be duplicated with a shadow line.
|
||||
await expect(page.locator('#content-area td[data-cell="B1"] .xlsx-cached')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('affiche la bannière listant les éléments non préservés', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
+194
-2
@@ -106,16 +106,208 @@ class TestXlsxIndexing:
|
||||
|
||||
assert ".xlsx" in SUPPORTED_EXTENSIONS
|
||||
|
||||
def test_xlsx_indexed_metadata_only(self, test_vault_dir, xlsx_file):
|
||||
def test_xlsx_indexes_sheet_names_and_headers(self, test_vault_dir, xlsx_file):
|
||||
"""#153 A5 — a workbook is searchable by its cell values."""
|
||||
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
|
||||
# Sheet names + header rows reach TF-IDF (was metadata-only before A5).
|
||||
assert "Budget" in info["content"]
|
||||
assert "Poste" in info["content"]
|
||||
assert info["content_preview"]
|
||||
assert info["title"] # filename-derived title
|
||||
|
||||
|
||||
class TestXlsxCachedValues:
|
||||
"""#153 A12 — show what Excel last computed next to each formula."""
|
||||
|
||||
def test_cached_result_is_shown_beside_the_formula(self, client, lossy_xlsx):
|
||||
from backend.xlsx_reader import render_sheets
|
||||
|
||||
html = render_sheets(Path(lossy_xlsx))[0]["html"]
|
||||
# A2 is "=A1*3" with <v>9</v> in the fixture.
|
||||
assert 'data-cell="A2"' in html
|
||||
assert "=A1*3" in html
|
||||
assert "xlsx-cached" in html
|
||||
assert ">9<" in html # the cached result Excel computed
|
||||
|
||||
def test_no_shadow_when_no_formula_carries_a_result(self, client, xlsx_file):
|
||||
from backend.xlsx_reader import render_sheets
|
||||
|
||||
html = render_sheets(Path(xlsx_file))[0]["html"]
|
||||
assert "xlsx-cached" not in html # budget.xlsx has no <v> at all
|
||||
|
||||
def test_plain_cells_are_never_duplicated(self, client, lossy_xlsx):
|
||||
from backend.xlsx_reader import render_sheets
|
||||
|
||||
html = render_sheets(Path(lossy_xlsx))[0]["html"]
|
||||
# A1 is the literal 3: the two reads agree, so only one value shows.
|
||||
assert 'data-cell="A1"' in html
|
||||
assert html.count("xlsx-cached") == 1 # only the formula cell
|
||||
|
||||
|
||||
class TestXlsxValueCoercion:
|
||||
"""#153 A10 — a typed value comes back with the type Excel would infer."""
|
||||
|
||||
def _write(self, client, xlsx_file, ref, value):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": {ref: value}, "force": True},
|
||||
)
|
||||
|
||||
def test_number_and_bool_are_stored_as_typed(self, client, xlsx_file):
|
||||
resp = self._write(
|
||||
client, xlsx_file, "D1", "42"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = self._write(client, xlsx_file, "D2", "VRAI")
|
||||
assert resp.status_code == 200
|
||||
resp = self._write(client, xlsx_file, "D3", "12/03/2026")
|
||||
assert resp.status_code == 200
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(xlsx_file)
|
||||
ws = wb["Budget"]
|
||||
assert ws["D1"].value == 42 and isinstance(ws["D1"].value, int)
|
||||
assert ws["D2"].value is True
|
||||
assert ws["D3"].value.year == 2026 and ws["D3"].value.month == 3
|
||||
assert ws["D3"].value.day == 12 # FR day-first, not 3 December
|
||||
wb.close()
|
||||
|
||||
def test_day_first_date_is_not_read_as_us(self, client, xlsx_file):
|
||||
"""'01/02/2026' is 1 February in French, not 2 January."""
|
||||
assert self._write(client, xlsx_file, "E1", "01/02/2026").status_code == 200
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(xlsx_file)
|
||||
d = wb["Budget"]["E1"].value
|
||||
wb.close()
|
||||
assert (d.month, d.day) == (2, 1)
|
||||
|
||||
def test_ambiguous_text_is_left_alone(self, client, xlsx_file):
|
||||
"""A version string or a partial date stays text, never a date."""
|
||||
assert self._write(client, xlsx_file, "F1", "3.14.2").status_code == 200
|
||||
assert self._write(client, xlsx_file, "F2", "Ref 12/34").status_code == 200
|
||||
assert self._write(client, xlsx_file, "F3", "12/2026").status_code == 200
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(xlsx_file)
|
||||
ws = wb["Budget"]
|
||||
assert ws["F1"].value == "3.14.2"
|
||||
assert ws["F2"].value == "Ref 12/34"
|
||||
assert ws["F3"].value == "12/2026"
|
||||
wb.close()
|
||||
|
||||
def test_plain_integer_text_becomes_a_number(self, client, xlsx_file):
|
||||
"""Typing a bare number yields a number, as it did before #153 A10."""
|
||||
assert self._write(client, xlsx_file, "H1", "75001").status_code == 200
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(xlsx_file)
|
||||
assert wb["Budget"]["H1"].value == 75001
|
||||
wb.close()
|
||||
|
||||
def test_formula_looking_date_stays_text(self, client, xlsx_file):
|
||||
"""A formula is never mistaken for a date (BUG-088 must not regress)."""
|
||||
assert self._write(client, xlsx_file, "G1", "=12/03/2026").status_code == 200
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(xlsx_file)
|
||||
c = wb["Budget"]["G1"]
|
||||
wb.close()
|
||||
assert c.data_type == "s"
|
||||
assert c.value == "=12/03/2026"
|
||||
|
||||
|
||||
class TestXlsxSearchable:
|
||||
"""#153 A5 — a keyword living in a CELL must make the file findable."""
|
||||
|
||||
def test_search_finds_a_word_stored_in_a_cell(self, test_vault_dir):
|
||||
"""A word typed in a CELL must make the workbook findable.
|
||||
|
||||
Deliberately hermetic: it drives the indexer and the inverted index
|
||||
directly instead of going through the HTTP reload, because both are
|
||||
process-wide singletons that other test modules mutate (some reload the
|
||||
module outright), which would make this test order-dependent.
|
||||
"""
|
||||
from openpyxl import Workbook
|
||||
|
||||
import backend.indexer as indexer
|
||||
import backend.search as search_mod
|
||||
|
||||
path = Path(test_vault_dir) / "fournisseurs.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Contacts"
|
||||
ws["A1"] = "Fournisseur"
|
||||
ws["A2"] = "Menuiserie Beaulieu"
|
||||
wb.save(path)
|
||||
|
||||
# Index exactly this vault, from scratch.
|
||||
indexer.index[VAULT] = {
|
||||
"files": [indexer._index_single_file_sync(VAULT, test_vault_dir, str(path))],
|
||||
"tags": {},
|
||||
"paths": [],
|
||||
"config": {},
|
||||
}
|
||||
entries = [f for f in indexer.index[VAULT]["files"] if f]
|
||||
assert entries, "le tableur n'a pas ete indexe"
|
||||
assert "Menuiserie" in entries[0]["content"], (
|
||||
f"contenu indexe : {entries[0]['content'][:80]!r}"
|
||||
)
|
||||
|
||||
search_mod.init_inverted_index()
|
||||
hits = [r["path"] for r in search_mod.search("Menuiserie", "all")]
|
||||
assert "fournisseurs.xlsx" in hits
|
||||
|
||||
def test_indexable_text_is_capped(self, tmp_path):
|
||||
"""A data dump must not flood the index."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
from backend.xlsx_reader import MAX_INDEX_CHARS, extract_indexable_text
|
||||
|
||||
path = tmp_path / "huge.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Big"
|
||||
for r in range(1, 400):
|
||||
ws.cell(row=r, column=1, value=f"ligne {r} " + "x" * 60)
|
||||
wb.save(path)
|
||||
|
||||
text = extract_indexable_text(path)
|
||||
assert len(text) <= MAX_INDEX_CHARS
|
||||
assert "Big" in text # the sheet name survives
|
||||
|
||||
def test_corrupt_workbook_indexes_as_empty_not_a_crash(self, tmp_path):
|
||||
from backend.xlsx_reader import extract_indexable_text
|
||||
|
||||
path = tmp_path / "broken.xlsx"
|
||||
path.write_bytes(b"not a zip at all")
|
||||
assert extract_indexable_text(path) == ""
|
||||
|
||||
def test_blank_rows_are_skipped(self, tmp_path):
|
||||
from openpyxl import Workbook
|
||||
|
||||
from backend.xlsx_reader import extract_indexable_text
|
||||
|
||||
path = tmp_path / "sparse.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "S"
|
||||
ws["A1"] = "Alpha"
|
||||
ws["A50"] = "Omega" # beyond the indexed prefix -> ignored on purpose
|
||||
wb.save(path)
|
||||
|
||||
text = extract_indexable_text(path)
|
||||
assert "Alpha" in text
|
||||
assert "Omega" not in text
|
||||
assert "\t\t" not in text # no run of empty columns
|
||||
|
||||
|
||||
# ── Edit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user