test_analyze.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. """Frozen-fixture suite for analyze.py classifier and aggregator.
  2. Each test parses a real-ish Clerk/Senate XML fixture via parse.py, then asserts
  3. classify_vote / aggregate behavior. Fixtures live in tests/fixtures/ and are
  4. trimmed/edited copies of real cached XMLs (see header comment in each).
  5. """
  6. import os
  7. import pytest
  8. import analyze
  9. import parse
  10. FIX = os.path.join(os.path.dirname(__file__), "fixtures")
  11. # ---------- Unit tests on pure helpers ----------
  12. @pytest.mark.parametrize("raw,expected", [
  13. ("Yea", "Yea"), ("Aye", "Yea"),
  14. ("Nay", "Nay"), ("No", "Nay"),
  15. ("Present", "Present"),
  16. ("Not Voting", "Not Voting"),
  17. (None, None), ("", ""),
  18. ])
  19. def test_norm_vote(raw, expected):
  20. assert analyze._norm_vote(raw) == expected
  21. @pytest.mark.parametrize("totals,expected", [
  22. ({"yea": 100, "nay": 50}, "Yea"),
  23. ({"yea": 50, "nay": 100}, "Nay"),
  24. ({"yea": 100, "nay": 100}, "Split"),
  25. ({"yea": 0, "nay": 0}, "Split"),
  26. ])
  27. def test_majority_position(totals, expected):
  28. assert analyze._majority_position(totals) == expected
  29. # ---------- Fixture loaders ----------
  30. def _house(name, year=2025, roll=900):
  31. rec, _ = parse.parse_house_vote(os.path.join(FIX, name), year, roll)
  32. assert rec is not None, f"parse_house_vote returned None for {name}"
  33. return rec
  34. def _senate(name, session=1, vnum=900):
  35. rec, _ = parse.parse_senate_vote(os.path.join(FIX, name), session, vnum)
  36. assert rec is not None, f"parse_senate_vote returned None for {name}"
  37. return rec
  38. # ---------- Classification tests ----------
  39. def test_partisan_house_helps_republicans():
  40. rec = _house("partisan_house.xml")
  41. # Jordan (R) Yea on R-Yea/D-Nay partisan vote
  42. cls = analyze.classify_vote(rec, "J000289")
  43. assert cls["member_vote"] == "Yea"
  44. assert cls["member_vote_norm"] == "Yea"
  45. assert cls["r_pos"] == "Yea"
  46. assert cls["d_pos"] == "Nay"
  47. assert cls["alignment"] == "Helped Republicans"
  48. assert cls["blocked"] is None
  49. def test_partisan_house_helps_democrats():
  50. rec = _house("partisan_house.xml")
  51. # Khanna (D) Nay on R-Yea/D-Nay partisan vote
  52. cls = analyze.classify_vote(rec, "K000389")
  53. assert cls["alignment"] == "Helped Democrats"
  54. def test_bipartisan_aye_no_normalization():
  55. rec = _house("bipartisan_house.xml")
  56. # Both parties Yea; Jordan voted "Aye" (procedural)
  57. cls = analyze.classify_vote(rec, "J000289")
  58. assert cls["member_vote"] == "Aye" # raw preserved
  59. assert cls["member_vote_norm"] == "Yea" # normalized
  60. assert cls["alignment"] == "Helped Both"
  61. # Khanna also Aye → Helped Both
  62. assert analyze.classify_vote(rec, "K000389")["alignment"] == "Helped Both"
  63. # Donalds "No" → normalized Nay, helped neither majority
  64. donalds = analyze.classify_vote(rec, "D000032")
  65. assert donalds["member_vote"] == "No"
  66. assert donalds["member_vote_norm"] == "Nay"
  67. assert donalds["alignment"] == "Helped Neither"
  68. def test_member_absent():
  69. rec = _house("partisan_house.xml")
  70. cls = analyze.classify_vote(rec, "Z999999") # not in record
  71. assert cls["alignment"].startswith("N/A: absent")
  72. assert cls["member_vote"] is None
  73. assert cls["member_vote_norm"] is None
  74. assert cls["blocked"] is None
  75. def test_split_party_no_false_helped():
  76. rec = _house("split_party_house.xml")
  77. # D is tied (100/100) → Split. Khanna (D) voted Yea; R majority is Yea.
  78. # Should classify as Helped Republicans only — NOT Helped Democrats.
  79. cls = analyze.classify_vote(rec, "K000389")
  80. assert cls["d_pos"] == "Split"
  81. assert cls["r_pos"] == "Yea"
  82. assert cls["alignment"] == "Helped Republicans"
  83. # And aggregate must not credit Khanna a "with-dem" or "against-dem"
  84. # because d_pos is Split (no party position to compare against).
  85. kpi = analyze.aggregate([rec], "K000389", "D", "house")
  86. assert kpi["voted_with_dem"] == 0
  87. assert kpi["voted_against_dem"] == 0
  88. assert kpi["voted_with_gop"] == 1
  89. # ---------- Failed-blocking tests ----------
  90. def test_failed_blocking_democrat_senate():
  91. rec = _senate("failed_blocking_senate.xml", session=1, vnum=910)
  92. # D-Yea / R-Nay / Failed. Manchin (D) voted Nay → blocked="Democrat".
  93. cls = analyze.classify_vote(rec, "S307")
  94. assert cls["d_pos"] == "Yea"
  95. assert cls["r_pos"] == "Nay"
  96. assert cls["blocked"] == "Democrat"
  97. # Cruz (R) Nay also gets blocked="Democrat" (he helped block Dem-backed bill)
  98. assert analyze.classify_vote(rec, "S288")["blocked"] == "Democrat"
  99. # Schumer (D) Yea → no block (he supported it)
  100. assert analyze.classify_vote(rec, "S168")["blocked"] is None
  101. def test_failed_blocking_republican_senate():
  102. rec = _senate("failed_blocking_rep_senate.xml", session=1, vnum=911)
  103. # R-Yea / D-Nay / Rejected. Collins (R) Nay → blocked="Republican".
  104. assert analyze.classify_vote(rec, "S289")["blocked"] == "Republican"
  105. # Warren (D) Nay → blocked="Republican" (Dem helped block GOP-backed bill)
  106. assert analyze.classify_vote(rec, "S366")["blocked"] == "Republican"
  107. # Cruz (R) Yea → no block
  108. assert analyze.classify_vote(rec, "S288")["blocked"] is None
  109. def test_failed_blocking_house_analog():
  110. rec = _house("failed_blocking_house.xml", year=2025, roll=920)
  111. # House R-Yea / D-Nay / Failed. Confirms classifier is chamber-agnostic.
  112. assert analyze.classify_vote(rec, "D000032")["blocked"] == "Republican"
  113. assert analyze.classify_vote(rec, "K000389")["blocked"] == "Republican"
  114. # ---------- Aggregate end-to-end ----------
  115. def test_aggregate_end_to_end_house():
  116. recs = [
  117. _house("partisan_house.xml", year=2025, roll=900),
  118. _house("bipartisan_house.xml", year=2025, roll=901),
  119. _house("failed_blocking_house.xml", year=2025, roll=920),
  120. _house("split_party_house.xml", year=2025, roll=930),
  121. ]
  122. # Khanna (D) — votes: Nay (partisan), Aye(=Yea) (bipartisan),
  123. # Nay (failed-blocking-house), Yea (split). All 4 are voting (no Present/NV).
  124. kpi = analyze.aggregate(recs, "K000389", "D", "house")
  125. assert kpi["chamber"] == "house"
  126. assert kpi["total"] == 4
  127. assert kpi["voting"] == 4
  128. assert kpi["yeas"] == 2 # bipartisan Aye→Yea, split Yea
  129. assert kpi["nays"] == 2 # partisan Nay, failed-blocking-house Nay
  130. assert kpi["present"] == 0
  131. assert kpi["nv"] == 0
  132. # voted_with_gop: vote matched R majority position (excluding R-Split votes).
  133. # partisan: R=Yea, Khanna=Nay → against. bipartisan: R=Yea, Khanna=Yea → with.
  134. # failed-blocking-house: R=Yea, Khanna=Nay → against. split: R=Yea, Khanna=Yea → with.
  135. assert kpi["voted_with_gop"] == 2
  136. assert kpi["voted_against_gop"] == 2
  137. # blocked counter: failed_blocking_house gives blocked="Republican" for Khanna
  138. assert kpi["blocked_rep_count"] == 1
  139. assert kpi["blocked_dem_count"] == 0
  140. assert kpi["lone_wolf_threshold"] == 5
  141. # alignment counts sum to total
  142. assert sum(kpi["alignment"].values()) == 4
  143. def test_lone_wolf_threshold_chamber_dependent():
  144. # partisan_house: R yea=210, R nay=4. Donalds (R) voted Nay → defector.
  145. # defectors == 4. House threshold 5 → lone wolf. Senate threshold 3 → NOT.
  146. rec = _house("partisan_house.xml")
  147. house_kpi = analyze.aggregate([rec], "D000032", "R", "house")
  148. assert house_kpi["lone_wolf"] == 1
  149. assert house_kpi["lone_wolf_threshold"] == 5
  150. senate_kpi = analyze.aggregate([rec], "D000032", "R", "senate")
  151. assert senate_kpi["lone_wolf"] == 0
  152. assert senate_kpi["lone_wolf_threshold"] == 3