"""Tests for political axis orientation validation. Validates that PVV, FVD, JA21, and SGP appear on the RIGHT side (mean-based) of the political compass, per AGENTS.md convention. """ import pytest duckdb = pytest.importorskip("duckdb") def _setup_party_axis_scores(db_path: str, rows: list): """Insert synthetic party_axis_scores rows. Args: db_path: Path to DuckDB database. rows: List of (party_abbrev, window_id, x_axis_aligned, y_axis_aligned). """ conn = duckdb.connect(db_path) conn.execute( """ CREATE TABLE IF NOT EXISTS party_axis_scores ( party_abbrev TEXT, window_id TEXT, x_axis_aligned DOUBLE, y_axis_aligned DOUBLE ) """ ) for party, window, x, y in rows: conn.execute( "INSERT INTO party_axis_scores (party_abbrev, window_id, x_axis_aligned, y_axis_aligned) VALUES (?, ?, ?, ?)", (party, window, x, y), ) conn.close() def _build_scores_by_party(db_path: str) -> dict: """Load aligned scores as {party: [[x,y] per window]} from DuckDB.""" from analysis.explorer_data import load_party_scores_all_windows_aligned return load_party_scores_all_windows_aligned(db_path) class TestAxisPoliticalOrientation: def test_build_window_party_scores_happy_path(self): from analysis.explorer_data import build_window_party_scores data = { "PVV": [[0.5, 0.3], [0.6, 0.4]], "FVD": [[0.4, 0.2], [0.5, 0.3]], "SP": [[-0.4, -0.2], [-0.5, -0.3]], "DENK": [[-0.3, -0.1], [-0.4, -0.2]], } result = build_window_party_scores(data, 0) assert result == { "PVV": [0.5, 0.3], "FVD": [0.4, 0.2], "SP": [-0.4, -0.2], "DENK": [-0.3, -0.1], } result = build_window_party_scores(data, 1) assert result == { "PVV": [0.6, 0.4], "FVD": [0.5, 0.3], "SP": [-0.5, -0.3], "DENK": [-0.4, -0.2], } def test_build_window_party_scores_out_of_range(self): from analysis.explorer_data import build_window_party_scores data = {"PVV": [[0.5, 0.3]], "SP": [[-0.4, -0.2]]} assert build_window_party_scores(data, 99) == {} assert build_window_party_scores(data, -1) == {} assert build_window_party_scores({}, 0) == {} def test_orientation_correct_no_flip_needed(self, tmp_path): db_path = str(tmp_path / "orientation.db") _setup_party_axis_scores( db_path, [ # Window 0: Correct orientation — right_mean > left_mean on both axes ("PVV", "w1", 0.8, 0.2), ("FVD", "w1", 0.6, 0.1), ("JA21", "w1", 0.5, 0.0), ("SGP", "w1", 0.4, 0.0), ("SP", "w1", -0.6, -0.2), ("DENK", "w1", -0.4, -0.1), ("PvdA", "w1", -0.5, -0.1), ("Volt", "w1", -0.3, -0.0), # Window 1: Same correct orientation ("PVV", "w2", 0.7, 0.3), ("FVD", "w2", 0.5, 0.2), ("JA21", "w2", 0.4, 0.1), ("SGP", "w2", 0.3, 0.0), ("SP", "w2", -0.5, -0.2), ("DENK", "w2", -0.3, -0.1), ("PvdA", "w2", -0.4, -0.1), ("Volt", "w2", -0.2, 0.0), ], ) scores_by_party = _build_scores_by_party(db_path) from analysis.explorer_data import build_window_party_scores from analysis.svd_labels import compute_flip_direction # 2 windows n_windows = max(len(v) for v in scores_by_party.values()) assert n_windows == 2 for window_idx in range(n_windows): party_scores = build_window_party_scores(scores_by_party, window_idx) flip_x = compute_flip_direction(1, party_scores) flip_y = compute_flip_direction(2, party_scores) assert flip_x is False, ( f"Window {window_idx}: right parties should already be on right (x-axis)" ) assert flip_y is False, ( f"Window {window_idx}: right parties should already be on right (y-axis)" ) def test_orientation_incorrect_triggers_flip(self, tmp_path): db_path = str(tmp_path / "orientation_flipped.db") _setup_party_axis_scores( db_path, [ # Window 0: Wrong orientation — right_mean < left_mean on x-axis ("PVV", "w1", -0.8, 0.0), # Right party on left ("FVD", "w1", -0.6, 0.0), ("JA21", "w1", -0.5, 0.0), ("SGP", "w1", -0.4, 0.0), ("SP", "w1", 0.6, 0.0), # Left party on right ("DENK", "w1", 0.4, 0.0), ], ) scores_by_party = _build_scores_by_party(db_path) from analysis.explorer_data import build_window_party_scores from analysis.svd_labels import compute_flip_direction party_scores = build_window_party_scores(scores_by_party, 0) flip_x = compute_flip_direction(1, party_scores) # Right mean = (-0.8 + -0.6 + -0.5 + -0.4) / 4 = -0.575 # Left mean = (0.6 + 0.4) / 2 = 0.5 # right_mean < left_mean → flip = True assert flip_x is True, "Right parties on left should trigger flip=True" def test_missing_party_graceful_skip(self, tmp_path): db_path = str(tmp_path / "partial.db") _setup_party_axis_scores( db_path, [ # Only PVV (right) and SP (left), no FVD/JA21/SGP ("PVV", "w1", 0.8, 0.2), ("SP", "w1", -0.6, -0.2), ("DENK", "w1", -0.4, -0.1), ], ) scores_by_party = _build_scores_by_party(db_path) from analysis.explorer_data import build_window_party_scores from analysis.svd_labels import compute_flip_direction party_scores = build_window_party_scores(scores_by_party, 0) # Should not raise — PVV and SP are in canonical sets, rest ignored flip_x = compute_flip_direction(1, party_scores) flip_y = compute_flip_direction(2, party_scores) # right_mean = 0.8, left_mean = (-0.6 + -0.4) / 2 = -0.5 # 0.8 > -0.5 → flip = False assert flip_x is False assert flip_y is False def test_party_name_aliasing_normalized(self, tmp_path): """Test that aliased party names are handled gracefully. DB may return 'GL' while canonical sets use 'GroenLinks-PvdA'. The test uses exact canonical names; _PARTY_NORMALIZE handles aliases. """ db_path = str(tmp_path / "aliased.db") _setup_party_axis_scores( db_path, [ # PVV and FVD under exact canonical names ("PVV", "w1", 0.8, 0.2), ("FVD", "w1", 0.6, 0.1), # Left parties under exact canonical names ("SP", "w1", -0.6, -0.2), ("DENK", "w1", -0.4, -0.1), ("Volt", "w1", -0.3, -0.1), ], ) scores_by_party = _build_scores_by_party(db_path) from analysis.explorer_data import build_window_party_scores from analysis.svd_labels import compute_flip_direction party_scores = build_window_party_scores(scores_by_party, 0) flip_x = compute_flip_direction(1, party_scores) # right_mean = (0.8 + 0.6) / 2 = 0.7 # left_mean = (-0.6 + -0.4 + -0.3) / 3 = -0.433 # 0.7 > -0.433 → flip = False assert flip_x is False def test_insufficient_data_returns_false(self, tmp_path): db_path = str(tmp_path / "insufficient.db") _setup_party_axis_scores( db_path, [ # Only left parties — no right parties ("SP", "w1", -0.6, -0.2), ("DENK", "w1", -0.4, -0.1), ], ) scores_by_party = _build_scores_by_party(db_path) from analysis.explorer_data import build_window_party_scores from analysis.svd_labels import compute_flip_direction party_scores = build_window_party_scores(scores_by_party, 0) flip = compute_flip_direction(1, party_scores) # No right parties in data → returns False (no flip) assert flip is False