diff --git a/tests/test_trajectories_pipeline_integration.py b/tests/test_trajectories_pipeline_integration.py new file mode 100644 index 0000000..f03a706 --- /dev/null +++ b/tests/test_trajectories_pipeline_integration.py @@ -0,0 +1,102 @@ +"""Integration test: full trajectory pipeline produces non-empty plot.""" + +import pytest + +from explorer import load_positions, load_party_map, select_trajectory_plot_data +from explorer_helpers import compute_party_centroids + + +def test_trajectory_pipeline_produces_traces(): + """Regression: trajectories must produce colored traces, not empty charts.""" + db_path = "data/motions.db" + window_size = "annual" + + # Stage 1: load positions + positions_by_window, _ = load_positions(db_path, window_size) + assert len(positions_by_window) > 0, "Expected at least one window" + total_mps = sum(len(v) for v in positions_by_window.values()) + assert total_mps > 0, "Expected MPs in windows" + + # Stage 2: load party map + party_map = load_party_map(db_path) + assert len(party_map) > 0, "Expected party map entries" + + # Stage 3: compute centroids + windows = list(positions_by_window.keys()) + centroids, mp_positions = compute_party_centroids( + positions_by_window, party_map, windows + ) + assert len(centroids) > 0, "Expected at least one party centroid" + + # Stage 4: select trajectory plot data (default party selection) + # Use the same defaults as build_trajectories_tab: CDA, D66, VVD if available + default_parties = [p for p in ["CDA", "D66", "VVD"] if p in centroids] + if not default_parties: + default_parties = list(centroids.keys())[:3] + + fig, trace_count, banner = select_trajectory_plot_data( + positions_by_window, + party_map, + windows, + selected_parties=default_parties, + smooth_alpha=0.35, + ) + + # Assertions + assert trace_count > 0, ( + f"Expected traces but got trace_count={trace_count}, banner={banner}" + ) + assert banner is None, f"Expected no fallback banner but got: {banner}" + assert len(fig.data) == trace_count, ( + f"fig.data ({len(fig.data)}) should equal trace_count ({trace_count})" + ) + + # Verify traces have real coordinates (not all NaN) + for trace in fig.data: + assert len(trace.x) > 0, f"Trace {trace.name} has no x values" + assert len(trace.y) > 0, f"Trace {trace.name} has no y values" + # At least some values should be real (not NaN) + import math + + real_x = sum( + 1 for v in trace.x if not (v is None or (isinstance(v, float) and v != v)) + ) # v != v is True only for NaN + real_y = sum( + 1 for v in trace.y if not (v is None or (isinstance(v, float) and v != v)) + ) + assert real_x > 0, f"Trace {trace.name} has all NaN x values" + assert real_y > 0, f"Trace {trace.name} has all NaN y values" + + +def test_trajectory_helper_skips_second_loop(): + """Regression: when select_trajectory_plot_data succeeds, build_trajectories_tab + should NOT add duplicate traces via the fallback loop. + + This test verifies that the helper produces clean output without relying on + the second loop in build_trajectories_tab. + """ + db_path = "data/motions.db" + window_size = "annual" + + positions_by_window, _ = load_positions(db_path, window_size) + party_map = load_party_map(db_path) + windows = list(positions_by_window.keys()) + centroids, _ = compute_party_centroids(positions_by_window, party_map, windows) + + # Use 6 parties like the app's multiselect + selected = list(centroids.keys())[:6] + + fig, trace_count, banner = select_trajectory_plot_data( + positions_by_window, + party_map, + windows, + selected_parties=selected, + smooth_alpha=0.35, + ) + + # Should produce exactly the number of selected parties (or fewer if some have all-NaN) + assert trace_count <= len(selected), ( + f"trace_count ({trace_count}) should not exceed selected ({len(selected)})" + ) + assert banner is None, "No fallback should be needed with valid data" + assert len(fig.data) == trace_count