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_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