468 lines
15 KiB
Python
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"]
|