Files
colibre/tests/test_figures.py
T

468 lines
15 KiB
Python

import polars as pl
import pytest
def _make_lff(rows):
return pl.LazyFrame(rows)
def test_compute_considerations_stats_basic():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
# u1 : social oui (Clause), env non (Sans objet)
{
"uid": "u1",
"considerationsSociales": "Clause sociale",
"considerationsEnvironnementales": "Sans objet",
},
# u2 : social non (Sans objet), env oui (Critère)
{
"uid": "u2",
"considerationsSociales": "Sans objet",
"considerationsEnvironnementales": "Critère environnemental",
},
# u3 : social oui (Marché réservé), env null
{
"uid": "u3",
"considerationsSociales": "Marché réservé",
"considerationsEnvironnementales": None,
},
# u4 : social autre valeur (pas "Sans objet"), env null
{
"uid": "u4",
"considerationsSociales": "Pas de considération sociale",
"considerationsEnvironnementales": "Sans objet",
},
]
)
stats = compute_considerations_stats(lff)
# champs_renseignes : basé sur sociales. 4 non-null / 4 total -> (4, 100%).
assert stats["champs_renseignes"] == (4, 100)
# Sociales renseignées : dén=4 non-null, num=3 != "Sans objet" (u1/u3/u4) -> (4, 3, 75%).
assert stats["sociales_renseignees"] == (4, 3, 75)
# Env renseignées : dén=3 non-null (u1/u2/u4), num=1 != "Sans objet" (u2) -> (3, 1, 33%).
assert stats["environnementales_renseignees"] == (3, 1, 33)
def test_compute_considerations_stats_dedup_per_uid():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
# u1 présent 2 fois (2 titulaires) -> compté une seule fois
{
"uid": "u1",
"considerationsSociales": "Clause sociale",
"considerationsEnvironnementales": "Sans objet",
},
{
"uid": "u1",
"considerationsSociales": "Clause sociale",
"considerationsEnvironnementales": "Sans objet",
},
{
"uid": "u2",
"considerationsSociales": "Sans objet",
"considerationsEnvironnementales": "Sans objet",
},
]
)
stats = compute_considerations_stats(lff)
# 2 uid distincts. Social 2 non-null, 1 != "Sans objet" (u1) -> (2, 1, 50%).
assert stats["champs_renseignes"] == (2, 100)
assert stats["sociales_renseignees"] == (2, 1, 50)
# Env 2 non-null, 0 != "Sans objet" -> (2, 0, 0%).
assert stats["environnementales_renseignees"] == (2, 0, 0)
def test_compute_considerations_stats_missing_column():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
{"uid": "u1", "considerationsSociales": "Clause sociale"},
{"uid": "u2", "considerationsSociales": "Sans objet"},
]
)
stats = compute_considerations_stats(lff)
# Colonne env absente -> (0, 0). Social : 2 non-null, 1 != "Sans objet" -> (2, 1, 50%).
assert stats["champs_renseignes"] == (2, 100)
assert stats["sociales_renseignees"] == (2, 1, 50)
assert stats["environnementales_renseignees"] == (0, 0)
def test_compute_considerations_stats_empty():
from src.figures import compute_considerations_stats
lff = pl.LazyFrame(
{
"uid": pl.Series([], dtype=pl.String),
"considerationsSociales": pl.Series([], dtype=pl.String),
"considerationsEnvironnementales": pl.Series([], dtype=pl.String),
}
)
stats = compute_considerations_stats(lff)
assert stats["champs_renseignes"] == (0, 0)
assert stats["sociales_renseignees"] == (0, 0)
assert stats["environnementales_renseignees"] == (0, 0)
def test_get_considerations_card_content_returns_three_progress_bars():
import dash_bootstrap_components as dbc
from dash import html
from src.figures import get_considerations_card_content
lff = pl.LazyFrame(
[
{
"uid": "u1",
"considerationsSociales": "Clause sociale",
"considerationsEnvironnementales": "Sans objet",
},
{
"uid": "u2",
"considerationsSociales": "Sans objet",
"considerationsEnvironnementales": "Critère environnemental",
},
]
)
div = get_considerations_card_content(lff)
assert isinstance(div, html.Div)
def find_progress(component, found):
if isinstance(component, dbc.Progress):
found.append(component)
children = getattr(component, "children", None)
if isinstance(children, (list, tuple)):
for c in children:
find_progress(c, found)
elif children is not None and not isinstance(children, str):
find_progress(children, found)
return found
inner_bars = [b for b in find_progress(div, []) if getattr(b, "bar", False)]
assert len(inner_bars) == 3
bar_ren, bar_social, bar_env = inner_bars
# Bar 1 : champs renseignés (2/2 = 100%, gris)
assert bar_ren.value == 100
assert bar_ren.color == "#6c757d"
# Bar 2 : sociales parmi renseignés (u1 != "Sans objet" -> 1/2 = 50%, rose)
assert bar_social.value == 50
assert bar_social.color == "#CC6677"
# Bar 3 : env parmi renseignés (u2 != "Sans objet" -> 1/2 = 50%, vert)
assert bar_env.value == 50
assert bar_env.color == "#117733"
def test_build_org_markers_groups_and_counts():
from src.figures import build_org_markers
lff = pl.LazyFrame(
[
{
"uid": "u1",
"acheteur_longitude": 2.35,
"acheteur_latitude": 48.85,
"acheteur_nom": "ACHETEUR A",
},
{
"uid": "u2",
"acheteur_longitude": 2.35,
"acheteur_latitude": 48.85,
"acheteur_nom": "ACHETEUR A",
},
{
"uid": "u3",
"acheteur_longitude": -1.68,
"acheteur_latitude": 48.11,
"acheteur_nom": "ACHETEUR B",
},
]
)
markers = build_org_markers(lff, "acheteur")
assert len(markers) == 2
marker_a = next(m for m in markers if m["tooltip"].startswith("ACHETEUR A"))
assert marker_a["tooltip"] == "ACHETEUR A (2 marchés)"
assert marker_a["lat"] == 48.85
assert marker_a["lon"] == 2.35
assert marker_a["marker_color"] == "#E69F00"
def test_build_org_markers_filters_null_coordinates():
from src.figures import build_org_markers
lff = pl.LazyFrame(
[
{
"uid": "u1",
"acheteur_longitude": None,
"acheteur_latitude": None,
"acheteur_nom": "ACHETEUR A",
},
{
"uid": "u2",
"acheteur_longitude": 2.35,
"acheteur_latitude": 48.85,
"acheteur_nom": "ACHETEUR B",
},
]
)
markers = build_org_markers(lff, "acheteur")
assert len(markers) == 1
assert markers[0]["tooltip"] == "ACHETEUR B (1 marchés)"
def test_build_org_markers_missing_columns_returns_empty():
from src.figures import build_org_markers
lff = pl.LazyFrame([{"uid": "u1", "acheteur_nom": "ACHETEUR A"}])
assert build_org_markers(lff, "acheteur") == []
assert build_org_markers(lff, "titulaire") == []
def _org_map_dff():
return pl.DataFrame(
[
{
"uid": "u1",
"acheteur_longitude": 2.35,
"acheteur_latitude": 48.85,
"acheteur_nom": "ACHETEUR A",
"titulaire_longitude": -1.68,
"titulaire_latitude": 48.11,
"titulaire_nom": "TITULAIRE A",
}
]
)
def test_get_org_location_map_centers_on_home_and_counterpart():
import dash_leaflet as dl
from src.figures import get_org_location_map
leaflet_map = get_org_location_map(_org_map_dff(), "acheteur", "test-map")
assert isinstance(leaflet_map, dl.Map)
# Centre = milieu du point acheteur (2.35, 48.85) et titulaire (-1.68, 48.11).
assert leaflet_map.center == pytest.approx([48.48, 0.335])
assert leaflet_map.zoom == 6
geojson_layers = [c for c in leaflet_map.children if isinstance(c, dl.GeoJSON)]
assert {layer.id for layer in geojson_layers} == {
"test-map-acheteur",
"test-map-titulaire",
}
def test_get_org_location_map_marks_home_org_marker_acheteur():
import dash_leaflet as dl
from src.figures import get_org_location_map
leaflet_map = get_org_location_map(_org_map_dff(), "acheteur", "test-map")
geojson_layers = {
layer.id: layer
for layer in leaflet_map.children
if isinstance(layer, dl.GeoJSON)
}
acheteur_features = geojson_layers["test-map-acheteur"].data["features"]
titulaire_features = geojson_layers["test-map-titulaire"].data["features"]
assert all(f["properties"]["is_home"] for f in acheteur_features)
assert all(not f["properties"].get("is_home") for f in titulaire_features)
def test_get_org_location_map_marks_home_org_marker_titulaire():
import dash_leaflet as dl
from src.figures import get_org_location_map
leaflet_map = get_org_location_map(_org_map_dff(), "titulaire", "test-map")
geojson_layers = {
layer.id: layer
for layer in leaflet_map.children
if isinstance(layer, dl.GeoJSON)
}
acheteur_features = geojson_layers["test-map-acheteur"].data["features"]
titulaire_features = geojson_layers["test-map-titulaire"].data["features"]
assert all(f["properties"]["is_home"] for f in titulaire_features)
assert all(not f["properties"].get("is_home") for f in acheteur_features)
def test_get_org_location_map_defaults_to_france_view_without_coordinates():
from src.figures import get_org_location_map
dff = pl.DataFrame(
[{"uid": "u1", "acheteur_nom": "ACHETEUR A", "titulaire_nom": "TITULAIRE A"}]
)
leaflet_map = get_org_location_map(dff, "acheteur", "test-map")
assert leaflet_map.center == [46.6, 2.2]
assert leaflet_map.zoom == 5
def test_bounds_to_center_zoom_single_point_uses_max_zoom():
from src.figures import bounds_to_center_zoom
center, zoom = bounds_to_center_zoom(48.85, 2.35, 48.85, 2.35, max_zoom=12)
assert center == [48.85, 2.35]
assert zoom == 12
def test_bounds_to_center_zoom_wider_bounds_give_smaller_zoom():
from src.figures import bounds_to_center_zoom
_, zoom_narrow = bounds_to_center_zoom(48.11, -1.68, 48.85, 2.35)
_, zoom_wide = bounds_to_center_zoom(16.23, -61.55, 51.0, 8.0)
assert zoom_wide < zoom_narrow
def test_bounds_to_center_zoom_center_is_bbox_midpoint():
from src.figures import bounds_to_center_zoom
center, _ = bounds_to_center_zoom(40.0, 0.0, 50.0, 4.0)
assert center == [45.0, 2.0]
def test_ag_grid_locale_text_translates_common_filter_labels():
from src.figures import ag_grid
grid = ag_grid("tableau_grid", [])
locale_text = grid.dashGridOptions["localeText"]
assert locale_text["equals"] == "Égal à"
assert locale_text["contains"] == "Contient"
assert locale_text["blank"] == "Vide"
assert locale_text["notBlank"] == "Non vide"
def test_ag_grid_keeps_base_appearance():
"""Pas de thème/className custom au Lot 1 (spec de design)."""
from src.figures import ag_grid
grid = ag_grid("tableau_grid", [])
assert getattr(grid, "className", None) in (None, "")
def test_ag_grid_always_shows_horizontal_scroll():
"""Réserve l'espace de la barre de défilement horizontale en permanence
(comportement natif Chrome). Sans effet sur le curseur en overlay de
Firefox, géré par l'OS/le navigateur, non contrôlable par l'appli."""
from src.figures import ag_grid
grid = ag_grid("tableau_grid", [])
assert grid.dashGridOptions["alwaysShowHorizontalScroll"] is True
def test_ag_grid_accepts_dict_id_and_custom_persisted_props():
"""Grille entité : id pattern-matching + persistance limitée au filterModel
(columnState géré via un store partagé, pas la persistance native)."""
from src.figures import ag_grid
gid = {"type": "acheteur-grid", "entity_id": "123", "year": "Toutes les années"}
grid = ag_grid(gid, [], persisted_props=["filterModel"])
assert grid.id == gid
assert list(grid.persisted_props) == ["filterModel"]
assert grid.persistence is True
def test_ag_grid_defaults_unchanged_for_tableau():
"""Sans args de persistance, comportement identique au Lot 1 (tableau.py)."""
from src.figures import ag_grid
grid = ag_grid("tableau_grid", [])
assert grid.id == "tableau_grid"
assert list(grid.persisted_props) == ["filterModel", "columnState"]
assert grid.persistence is True
def test_get_top_org_ag_grid_returns_grid_with_rowdata():
"""Le top 10 devient une AG Grid client-side (rowData directe, pas de
getRowsRequest server-side)."""
import dash_ag_grid as dag
from src.db import query_marches
from src.figures import get_top_org_ag_grid
lff = query_marches("TRUE", (), columns=None).lazy()
grid = get_top_org_ag_grid(lff, "titulaire", ["titulaire_distance"])
assert isinstance(grid, dag.AgGrid)
assert grid.rowData is not None and len(grid.rowData) > 0
# Colonne d'agrégat présente ; colonnes définies.
assert any(c.get("field") == "Attributions" for c in grid.columnDefs)
# Pas de persistance (petit tableau statique, régénéré à chaque fiche).
assert grid.persistence in (False, None)
def test_get_top_org_ag_grid_empty_returns_div():
"""Données vides → html.Div() (parité get_top_org_table)."""
import polars as pl
from dash import html
from src.figures import get_top_org_ag_grid
empty = pl.DataFrame(
{"uid": [], "titulaire_id": [], "titulaire_nom": [], "titulaire_distance": []}
).lazy()
out = get_top_org_ag_grid(empty, "titulaire", ["titulaire_distance"])
assert isinstance(out, html.Div)
def test_get_top_org_table_still_returns_datatable_after_refactor():
"""Non-régression : get_top_org_table (utilisé par observatoire.py) garde
son comportement après extraction du helper d'agrégation partagé."""
import dash_ag_grid # noqa: F401
from dash import html
from src.db import query_marches
from src.figures import DataTable, get_top_org_table
lff = query_marches("TRUE", (), columns=None).lazy()
out = get_top_org_table(lff, "titulaire", ["titulaire_distance"])
assert isinstance(out, (DataTable, html.Div))
def test_top_org_aggregate_does_not_mutate_extra_columns():
"""Le helper ne mute pas la liste extra_columns passée (bug latent de
l'ancien get_top_org_table, qui faisait extra_columns.append(...))."""
from src.db import query_marches
from src.figures import _top_org_aggregate
extra = ["titulaire_distance"]
_top_org_aggregate(
query_marches("TRUE", (), columns=None).lazy(), "titulaire", extra
)
assert extra == ["titulaire_distance"]