Files
colibre/tests/test_figures.py
Colin Maudry eb8f54dbf2 fix(figures): calculer center/zoom côté serveur au lieu d'utiliser bounds
La prop `bounds` de dash-leaflet lève une exception JS (TypeError sur
.equals() avec une valeur précédente indéfinie) au tout premier montage
du composant Map — confirmé via la stack trace navigateur, reproductible
même en dernière version (1.1.3). Cette exception laissait le clustering
dans un état incohérent (declustering impossible avant un zoom complet
suivi d'un dézoom).

bounds_to_center_zoom calcule un center/zoom approximatifs (projection
Web Mercator, taille de conteneur supposée) pour cadrer l'organisme et
ses contreparties, en évitant complètement la prop `bounds` bugguée.
Cadrage moins précis qu'un vrai fitBounds() navigateur, mais robuste.
2026-07-03 22:54:21 +02:00

352 lines
11 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]