fix(tableau): vues sauvegardées en AST canonique, cache du comptage, synchro visibilité colonnes (#41)

This commit is contained in:
Colin Maudry
2026-07-10 14:44:14 +02:00
parent 0bfac680a5
commit 4f36022443
6 changed files with 389 additions and 19 deletions
+59 -10
View File
@@ -1,10 +1,16 @@
"""Régression revue finale #41 : apply_saved_view (callback qui RAPPELLE une vue
sauvegardée) ne doit pas planter si row["query"] est encore au format
pré-migration (query string, ex. "filtres=a&tris=b"), stocké par l'ancienne
build_view_query avant que Task 10 ne migre save_view vers du JSON
{"filterModel": ..., "columnState": ...}.
build_view_query avant que Task 10 ne migre save_view vers du JSON.
Depuis le round 2 de la revue finale, le format JSON stocké est
{"ast": ..., "columnState": ...} (AST canonique, indépendant de l'UI) plutôt
que {"filterModel": ..., "columnState": ...} (filterModel brut d'AG Grid) :
cf. spec de conception. apply_saved_view doit aussi resynchroniser
tableau-hidden-columns à partir du columnState rappelé.
"""
import json
from unittest.mock import patch
import dash
@@ -13,6 +19,7 @@ import src.app # noqa: F401 # instancie l'app → register_page() des pages
from src.auth import db as auth_db
from src.pages import tableau
from src.saved_views import db as saved_views_db
from src.utils.query_ast import And, Condition, ast_to_dict
def _make_user(email="u@ex.fr"):
@@ -41,21 +48,26 @@ def test_apply_saved_view_old_format_returns_no_update(monkeypatch, users_db_pat
monkeypatch.setattr(tableau, "ctx", _Ctx)
with patch.object(tableau, "current_user", _fake_user(uid)):
filter_model, column_state = tableau.apply_saved_view(
filter_model, column_state, hidden_columns = tableau.apply_saved_view(
[1], [{"type": "saved-view-item", "index": view_id}]
)
assert filter_model is dash.no_update
assert column_state is dash.no_update
assert hidden_columns is dash.no_update
def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path):
"""row["query"] au format post-round-2 : {"ast": ..., "columnState": ...},
AST canonique plutôt que filterModel brut d'AG Grid."""
saved_views_db.init_schema()
uid = _make_user()
query = (
'{"filterModel": {"objet": {"filterType": "text", "filter": "route"}}, '
'"columnState": [{"colId": "montant", "sort": "desc"}]}'
)
ast = And([Condition("objet", "contains", "route")])
column_state = [
{"colId": "montant", "sort": "desc"},
{"colId": "acheteur_nom", "hide": True},
]
query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state})
saved_views_db.upsert(uid, "tableau", "Vue récente", query)
view_id = saved_views_db.list_views(uid, "tableau")[0]["id"]
@@ -63,9 +75,46 @@ def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path):
monkeypatch.setattr(tableau, "ctx", _Ctx)
with patch.object(tableau, "current_user", _fake_user(uid)):
filter_model, column_state = tableau.apply_saved_view(
filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view(
[1], [{"type": "saved-view-item", "index": view_id}]
)
assert filter_model == {"objet": {"filterType": "text", "filter": "route"}}
assert column_state == [{"colId": "montant", "sort": "desc"}]
assert filter_model == {
"objet": {"filterType": "text", "type": "contains", "filter": "route"}
}
assert returned_column_state == column_state
# tableau-hidden-columns doit être resynchronisé à partir du columnState
# rappelé (revue finale #41, round 2) : seules les colonnes avec hide=True.
assert hidden_columns == ["acheteur_nom"]
def test_apply_saved_view_missing_ast_key_degrades_gracefully(
monkeypatch, users_db_path
):
"""Vue stockée dans un format intermédiaire (sans clé "ast", ex. l'ancien
format {"filterModel": ..., "columnState": ...} produit avant le round 2) :
ast_from_dict(None) -> None, ast_to_filtermodel(None, schema) -> {} — la
vue se rappelle sans filtre plutôt que de planter le callback."""
saved_views_db.init_schema()
uid = _make_user()
column_state = [{"colId": "montant", "sort": "desc"}]
query = json.dumps(
{
"filterModel": {"objet": {"filterType": "text", "filter": "route"}},
"columnState": column_state,
}
)
saved_views_db.upsert(uid, "tableau", "Vue ancien format", query)
view_id = saved_views_db.list_views(uid, "tableau")[0]["id"]
_Ctx.triggered_id = {"type": "saved-view-item", "index": view_id}
monkeypatch.setattr(tableau, "ctx", _Ctx)
with patch.object(tableau, "current_user", _fake_user(uid)):
filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view(
[1], [{"type": "saved-view-item", "index": view_id}]
)
assert filter_model == {}
assert returned_column_state == column_state
assert hidden_columns == []
+51
View File
@@ -1,10 +1,38 @@
from unittest.mock import patch
import pytest
import src.app # noqa: F401 # instancie l'app → register_page() des pages
from src.pages.tableau import get_rows_tableau
from src.utils import grid as grid_module
from src.utils.grid import export_dataframe, fetch_grid_page, grid_column_defs
@pytest.fixture(scope="module")
def flask_app():
"""Minimal Flask app with SimpleCache so @cache.memoize() works in tests
(même pattern que tests/test_table.py)."""
from flask import Flask
from src.utils.cache import cache
app = Flask(__name__)
cache.init_app(app, config={"CACHE_TYPE": "SimpleCache"})
return app
@pytest.fixture(autouse=True)
def reset_cache(flask_app):
from src.utils.cache import cache
with flask_app.app_context():
try:
cache.clear()
except (RuntimeError, AttributeError):
pass
yield
def test_column_defs_have_field_and_filter():
defs = grid_column_defs(hidden_columns=[])
by_field = {d["field"]: d for d in defs}
@@ -68,3 +96,26 @@ def test_get_rows_tableau_tracks_search_once_per_filter_not_per_scroll_block():
get_rows_tableau({"filterModel": fm, "startRow": 100, "endRow": 200})
get_rows_tableau({"filterModel": fm, "startRow": 200, "endRow": 300})
mocked.assert_called_once()
def test_fetch_grid_page_caches_count_across_scroll_blocks(flask_app, monkeypatch):
"""Régression revue finale #41 : count_marches ne doit être appelé qu'une
fois pour des blocs de défilement successifs partageant le même
where_sql/params (même filtre, start_row différent)."""
call_count = {"n": 0}
real_count_marches = grid_module.count_marches
def counting_count_marches(where_sql, params):
call_count["n"] += 1
return real_count_marches(where_sql, params)
monkeypatch.setattr(grid_module, "count_marches", counting_count_marches)
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}}
with flask_app.app_context():
_, total1 = fetch_grid_page(fm, None, 0, 20)
_, total2 = fetch_grid_page(fm, None, 20, 40)
_, total3 = fetch_grid_page(fm, None, 40, 60)
assert call_count["n"] == 1
assert total1 == total2 == total3
+101
View File
@@ -7,6 +7,7 @@ from src.utils.query_ast import (
Or,
ast_from_dict,
ast_to_dict,
ast_to_filtermodel,
ast_to_sql,
filtermodel_to_ast,
sort_model_to_sql,
@@ -235,3 +236,103 @@ def test_ast_dict_roundtrip():
def test_ast_dict_none():
assert ast_to_dict(None) is None
assert ast_from_dict(None) is None
def _roundtrip_sql(fm):
"""Compile fm -> ast -> filterModel -> ast à nouveau, renvoie (sql, params)
de la première et de la seconde compilation, pour vérifier l'équivalence
sémantique du round-trip (pas l'égalité dict-à-dict)."""
ast1 = filtermodel_to_ast(fm, SCHEMA)
rebuilt_fm = ast_to_filtermodel(ast1, SCHEMA)
ast2 = filtermodel_to_ast(rebuilt_fm, SCHEMA)
return ast_to_sql(ast1, SCHEMA), ast_to_sql(ast2, SCHEMA)
def test_ast_to_filtermodel_roundtrip_text_contains():
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_number_greaterthan():
fm = {"montant": {"filterType": "number", "type": "greaterThan", "filter": 40000}}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_number_inrange():
fm = {
"montant": {
"filterType": "number",
"type": "inRange",
"filter": 100,
"filterTo": 200,
}
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_date_greaterthan():
fm = {
"dateNotification": {
"filterType": "date",
"type": "greaterThan",
"dateFrom": "2022-01-01",
}
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_two_conditions_or():
fm = {
"objet": {
"filterType": "text",
"operator": "OR",
"condition1": {"filterType": "text", "type": "contains", "filter": "beton"},
"condition2": {
"filterType": "text",
"type": "contains",
"filter": "ciment",
},
}
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_multiple_columns():
fm = {
"objet": {"filterType": "text", "type": "contains", "filter": "voirie"},
"montant": {"filterType": "number", "type": "greaterThan", "filter": 1000},
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_none_and_empty_and():
assert ast_to_filtermodel(None, SCHEMA) == {}
assert ast_to_filtermodel(And([]), SCHEMA) == {}
def test_ast_to_filtermodel_skips_not_with_warning():
node = And([Not(Condition("objet", "contains", "x"))])
assert ast_to_filtermodel(node, SCHEMA) == {}
def test_ast_to_filtermodel_skips_mismatched_columns_with_warning():
node = And(
[Or([Condition("objet", "contains", "a"), Condition("montant", "gt", 1)])]
)
assert ast_to_filtermodel(node, SCHEMA) == {}
def test_ast_to_filtermodel_bare_single_condition():
"""filtermodel_to_ast enveloppe toujours dans And, mais on tolère un nœud
non enveloppé (Condition seule) en entrée, défensivement."""
node = Condition("objet", "contains", "voirie")
fm = ast_to_filtermodel(node, SCHEMA)
assert fm == {
"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}
}