Files
colibre/tests/test_grid.py
T

234 lines
8.6 KiB
Python

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, reset_view
from src.utils import grid as grid_module
from src.utils.grid import (
apply_persisted_layout,
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}
assert "objet" in by_field
# filtre texte par défaut
assert by_field["objet"]["filter"] == "agTextColumnFilter"
# montant est numérique
assert by_field["montant"]["filter"] == "agNumberColumnFilter"
# headerTooltip présent (définition de colonne)
assert "headerTooltip" in by_field["objet"]
def test_column_defs_hidden_flag():
defs = grid_column_defs(hidden_columns=["objet"])
by_field = {d["field"]: d for d in defs}
assert by_field["objet"]["hide"] is True
def test_fetch_grid_page_returns_rows_and_count():
rows, total, total_unique = fetch_grid_page(None, None, 0, 20)
assert isinstance(rows, list)
assert isinstance(total, int)
assert total >= len(rows)
assert total_unique <= total
if rows:
# postprocess_page ajoute une colonne 'marche' avec un lien
assert "marche" in rows[0]
def test_fetch_grid_page_filter_reduces_count():
_, total_all, _ = fetch_grid_page(None, None, 0, 1)
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}}
rows, total_filtered, _ = fetch_grid_page(fm, None, 0, 20)
assert total_filtered <= total_all
assert rows == [] and total_filtered == 0
def test_fetch_grid_page_offset_slicing():
rows, _, _ = fetch_grid_page(None, None, 0, 5)
assert len(rows) <= 5
def test_export_dataframe_excludes_hidden_columns():
df = export_dataframe(None, None, hidden_columns=["objet"])
assert "objet" not in df.columns
def test_export_dataframe_applies_filter():
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}}
df = export_dataframe(fm, None, hidden_columns=[])
assert df.height == 0
def test_export_dataframe_applies_base_scope():
"""base_where_sql restreint l'export à un sous-ensemble (ex. un acheteur),
en plus du filterModel — utilisé par acheteur.py/titulaire.py (#41)."""
# Récupère un acheteur_id présent dans le jeu de test.
unscoped = export_dataframe(None, None, hidden_columns=[])
assert unscoped.height > 0
an_acheteur = unscoped["acheteur_id"][0]
scoped = export_dataframe(
None,
None,
hidden_columns=[],
base_where_sql="acheteur_id = ?",
base_params=(an_acheteur,),
)
assert scoped.height > 0
assert scoped.height <= unscoped.height
assert set(scoped["acheteur_id"].to_list()) == {an_acheteur}
def test_get_rows_tableau_tracks_search_once_per_filter_not_per_scroll_block():
"""Régression revue finale #41 : AG Grid envoie une getRowsRequest par bloc
de défilement infini, avec le même filterModel tant que le filtre ne
change pas. track_search ne doit être appelé qu'une fois par filtre (au
premier bloc, startRow == 0), pas une fois par bloc défilé."""
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}}
with patch("src.pages.tableau.track_search") as mocked:
get_rows_tableau({"filterModel": fm, "startRow": 0, "endRow": 100})
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
def test_reset_view_clears_filter_and_sort():
"""Régression : le bouton Réinitialiser ('Supprime tous les filtres et les
tris') ne remettait à zéro que filterModel, jamais le tri (#41)."""
column_state = [{"colId": "objet", "sort": "asc", "sortIndex": 0, "width": 200}]
filter_model, new_column_state = reset_view(1, column_state)
assert filter_model == {}
assert new_column_state == [
{"colId": "objet", "sort": None, "sortIndex": None, "width": 200}
]
def test_reset_view_preserves_column_width():
"""Le bouton Réinitialiser ne doit remettre à zéro que les filtres et les
tris, pas la largeur (ni le pinning/ordre) des colonnes."""
column_state = [
{"colId": "objet", "sort": "asc", "sortIndex": 0, "width": 350, "hide": True},
{
"colId": "montant",
"sort": None,
"sortIndex": None,
"width": 120,
"hide": False,
},
]
_, new_column_state = reset_view(1, column_state)
assert [c["width"] for c in new_column_state] == [350, 120]
assert [c["hide"] for c in new_column_state] == [True, False]
assert all(c["sort"] is None and c["sortIndex"] is None for c in new_column_state)
def test_apply_persisted_layout_restores_width_and_order():
"""Régression : la largeur et l'ordre des colonnes choisis par
l'utilisateur étaient perdus au rechargement de la page, écrasés par les
columnDefs régénérés par apply_hidden_columns à chaque chargement (#47)."""
defs = [
{"field": "marche", "width": 60},
{"field": "objet", "minWidth": 360},
{"field": "montant", "width": 150},
{"field": "acheteur_nom", "width": 200},
]
# L'utilisateur a redimensionné "montant" et déplacé "acheteur_nom" avant "objet"
column_state = [
{"colId": "marche", "width": 60},
{"colId": "acheteur_nom", "width": 200},
{"colId": "objet", "width": 500},
{"colId": "montant", "width": 320},
]
result = apply_persisted_layout(defs, column_state)
assert [d["field"] for d in result] == [
"marche",
"acheteur_nom",
"objet",
"montant",
]
assert next(d for d in result if d["field"] == "montant")["width"] == 320
# "objet" n'a pas de clé "width" dans columnDefs (minWidth seulement, cf.
# grid_column_defs) : columnState en fournit une, elle doit être reprise.
assert next(d for d in result if d["field"] == "objet")["width"] == 500
def test_apply_persisted_layout_places_unknown_columns_last():
"""Une colonne absente du columnState persisté (jamais vue par
l'utilisateur, ex. ajout au schéma) doit rester à la fin plutôt que de
faire planter le tri."""
defs = [
{"field": "marche", "width": 60},
{"field": "objet", "minWidth": 360},
{"field": "nouvelle_colonne", "width": 130},
]
column_state = [
{"colId": "objet", "width": 400},
{"colId": "marche", "width": 60},
]
result = apply_persisted_layout(defs, column_state)
assert [d["field"] for d in result] == ["objet", "marche", "nouvelle_colonne"]
def test_apply_persisted_layout_noop_without_prior_state():
"""Premier chargement (jamais de columnState persisté) : columnDefs
inchangés, pas d'erreur sur None/[]."""
defs = [{"field": "marche", "width": 60}, {"field": "objet", "minWidth": 360}]
assert apply_persisted_layout(defs, None) == defs
assert apply_persisted_layout(defs, []) == defs