3034e911e7
Remplace le verrou de dérive à compteur (suppress-next) par une détection événementielle : eventListeners AG Grid (filterChanged/sortChanged) → dashAgGridFunctions.hideShareOnUserAction, qui efface active-view sur action utilisateur (source != 'api') et ignore l'écho de l'application programmatique. Le compteur suppress-next souffrait d'une course d'ordonnancement Dash (l'écho lisait la valeur pré-batch) et le diff de columnState était impraticable (état réémis bruité). La visibilité du bloc bascule via la classe (d-flex/d-none) et non le style inline, car .d-flex est display:flex !important. Task 7 : test E2E de bout en bout (application + masquage sur action).
128 lines
4.8 KiB
Python
128 lines
4.8 KiB
Python
"""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.
|
|
|
|
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
|
|
|
|
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"):
|
|
auth_db.init_schema()
|
|
return auth_db.create_user(email, "hash")
|
|
|
|
|
|
def _fake_user(user_id, authenticated=True):
|
|
user = type("U", (), {})()
|
|
user.is_authenticated = authenticated
|
|
user.id = user_id
|
|
return user
|
|
|
|
|
|
class _Ctx:
|
|
triggered_id = None
|
|
|
|
|
|
def test_apply_saved_view_old_format_returns_no_update(monkeypatch, users_db_path):
|
|
saved_views_db.init_schema()
|
|
uid = _make_user()
|
|
saved_views_db.upsert(uid, "tableau", "Vue historique", "filtres=a&tris=b")
|
|
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, column_state, hidden_columns, active = 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
|
|
assert active 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()
|
|
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"]
|
|
|
|
_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, active = (
|
|
tableau.apply_saved_view(
|
|
[1], [{"type": "saved-view-item", "index": view_id}]
|
|
)
|
|
)
|
|
|
|
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"]
|
|
# active-view alimente le bloc de partage (token + URL courte).
|
|
assert active["token"] and active["url"].endswith(f"_{active['token']}")
|
|
|
|
|
|
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, _active = (
|
|
tableau.apply_saved_view(
|
|
[1], [{"type": "saved-view-item", "index": view_id}]
|
|
)
|
|
)
|
|
|
|
assert filter_model == {}
|
|
assert returned_column_state == column_state
|
|
assert hidden_columns == []
|