| 1 | """ |
| 2 | Tests for the CREC procedural-event detector (crec.py). |
| 3 | |
| 4 | Covers: |
| 5 | - _VOICE_VOTE regex fires on canonical floor phrases |
| 6 | - _UC regex fires on unanimous-consent phrases |
| 7 | - _CLOTURE regex fires on cloture phrases |
| 8 | - _SUSPENSION, _MTR, _DISCHARGE patterns |
| 9 | - _detect_events() yields correct kind/chamber/date |
| 10 | - members_jsonb is always None (no individual record for voice votes) |
| 11 | - source_url is always a .gov URL |
| 12 | - _extract_text_from_xml() handles valid and malformed XML |
| 13 | - _infer_chamber() maps filenames correctly |
| 14 | - _context_snippet() extracts reasonable surrounding text |
| 15 | - Idempotency: re-processing the same text does not double-insert |
| 16 | """ |
| 17 | |
| 18 | import io |
| 19 | import re |
| 20 | import sqlite3 |
| 21 | import tempfile |
| 22 | import xml.etree.ElementTree as ET |
| 23 | |
| 24 | import pytest |
| 25 | |
| 26 | from ingest.pollers.crec import ( |
| 27 | _CLOTURE, |
| 28 | _DISCHARGE, |
| 29 | _MTR, |
| 30 | _SUSPENSION, |
| 31 | _UC, |
| 32 | _VOICE_VOTE, |
| 33 | _context_snippet, |
| 34 | _detect_events, |
| 35 | _ensure_tables, |
| 36 | _extract_text_from_xml, |
| 37 | _infer_chamber, |
| 38 | poll_date, |
| 39 | ) |
| 40 | from ingest.db import get_connection, init_schema |
| 41 | from ingest.source_validator import is_allowlisted |
| 42 | |
| 43 | |
| 44 | |
| 45 | @pytest.fixture() |
| 46 | def temp_db(): |
| 47 | """In-memory (temp-file) SQLite DB with schema + procedural_events table.""" |
| 48 | tmp = tempfile.mktemp(suffix=".db") |
| 49 | conn = get_connection(tmp) |
| 50 | init_schema(conn) |
| 51 | _ensure_tables(conn) |
| 52 | yield conn |
| 53 | conn.close() |
| 54 | import os; os.unlink(tmp) |
| 55 | |
| 56 | |
| 57 | |
| 58 | class TestVoiceVotePattern: |
| 59 | """The PRESIDING OFFICER uses specific floor phrases to declare voice-vote results.""" |
| 60 | |
| 61 | def test_ayes_have_it_lowercase(self): |
| 62 | assert _VOICE_VOTE.search("the ayes have it") |
| 63 | |
| 64 | def test_ayes_have_it_titlecase(self): |
| 65 | assert _VOICE_VOTE.search("The Ayes Have It") |
| 66 | |
| 67 | def test_yeas_have_it(self): |
| 68 | assert _VOICE_VOTE.search("the yeas have it") |
| 69 | |
| 70 | def test_noes_have_it(self): |
| 71 | assert _VOICE_VOTE.search("the noes have it") |
| 72 | |
| 73 | def test_nays_have_it(self): |
| 74 | assert _VOICE_VOTE.search("the nays have it") |
| 75 | |
| 76 | def test_double_declaration(self): |
| 77 | assert _VOICE_VOTE.search("the ayes have it, the ayes have it") |
| 78 | |
| 79 | def test_embedded_in_sentence(self): |
| 80 | text = ( |
| 81 | "The PRESIDING OFFICER. The question is on the amendment. " |
| 82 | "The ayes have it. The amendment is agreed to." |
| 83 | ) |
| 84 | assert _VOICE_VOTE.search(text) |
| 85 | |
| 86 | def test_no_false_positive_on_roll_call(self): |
| 87 | text = "The yeas are 67, nays 33. The motion is agreed to." |
| 88 | assert not _VOICE_VOTE.search(text) |
| 89 | |
| 90 | def test_aye_singular(self): |
| 91 | assert _VOICE_VOTE.search("the aye has it") |
| 92 | |
| 93 | def test_partial_words_no_match(self): |
| 94 | assert not _VOICE_VOTE.search("ayesha") |
| 95 | |
| 96 | |
| 97 | |
| 98 | class TestUCPattern: |
| 99 | def test_without_objection(self): |
| 100 | assert _UC.search("Without objection, so ordered.") |
| 101 | |
| 102 | def test_so_ordered(self): |
| 103 | assert _UC.search("So ordered.") |
| 104 | |
| 105 | def test_unanimous_consent(self): |
| 106 | assert _UC.search("Mr. Speaker, I ask unanimous consent to revise and extend") |
| 107 | |
| 108 | def test_case_insensitive(self): |
| 109 | assert _UC.search("WITHOUT OBJECTION") |
| 110 | assert _UC.search("unanimous CONSENT") |
| 111 | |
| 112 | def test_embedded(self): |
| 113 | text = "The Senator from Ohio asks unanimous consent that the Senate proceed." |
| 114 | assert _UC.search(text) |
| 115 | |
| 116 | def test_no_match_on_unrelated(self): |
| 117 | assert not _UC.search("The bill passed by a recorded vote of 52 to 48.") |
| 118 | |
| 119 | |
| 120 | |
| 121 | class TestCloturePattern: |
| 122 | def test_cloture_motion(self): |
| 123 | assert _CLOTURE.search("A cloture motion was filed on the nomination.") |
| 124 | |
| 125 | def test_invoke_cloture(self): |
| 126 | assert _CLOTURE.search("The Senate voted to invoke cloture.") |
| 127 | |
| 128 | def test_vote_on_cloture(self): |
| 129 | assert _CLOTURE.search("A vote on cloture is scheduled for Monday.") |
| 130 | |
| 131 | def test_cloture_was_invoked(self): |
| 132 | assert _CLOTURE.search("Cloture was invoked by a vote of 60-38.") |
| 133 | |
| 134 | def test_motion_to_invoke_cloture(self): |
| 135 | assert _CLOTURE.search("A motion to invoke cloture on the bill was filed.") |
| 136 | |
| 137 | def test_no_false_positive(self): |
| 138 | assert not _CLOTURE.search("The committee voted on the closure of the hearing room.") |
| 139 | |
| 140 | |
| 141 | |
| 142 | class TestSuspensionPattern: |
| 143 | def test_suspend_the_rules(self): |
| 144 | assert _SUSPENSION.search("On motion to suspend the rules and pass the bill") |
| 145 | |
| 146 | def test_suspension_of_the_rules(self): |
| 147 | assert _SUSPENSION.search("Suspension of the rules - H.R. 1234") |
| 148 | |
| 149 | def test_case_insensitive(self): |
| 150 | assert _SUSPENSION.search("SUSPEND THE RULES") |
| 151 | |
| 152 | def test_no_false_positive_on_other_suspend(self): |
| 153 | text = "The officer was suspended for misconduct." |
| 154 | assert not _SUSPENSION.search(text) |
| 155 | |
| 156 | |
| 157 | |
| 158 | class TestMTRPattern: |
| 159 | def test_basic(self): |
| 160 | assert _MTR.search("The gentleman offers a motion to recommit the bill.") |
| 161 | |
| 162 | def test_case_insensitive(self): |
| 163 | assert _MTR.search("MOTION TO RECOMMIT") |
| 164 | |
| 165 | def test_no_false_positive(self): |
| 166 | assert not _MTR.search("The motion to table failed.") |
| 167 | |
| 168 | |
| 169 | |
| 170 | class TestDischargePattern: |
| 171 | def test_discharge_petition(self): |
| 172 | assert _DISCHARGE.search("A discharge petition was filed for H.R. 10.") |
| 173 | |
| 174 | def test_discharged_from_committee(self): |
| 175 | assert _DISCHARGE.search("The bill was discharged from committee.") |
| 176 | |
| 177 | def test_discharge_calendar(self): |
| 178 | assert _DISCHARGE.search("The measure was placed on the discharge calendar.") |
| 179 | |
| 180 | def test_no_false_positive(self): |
| 181 | assert not _DISCHARGE.search("The soldier was honorably discharged.") |
| 182 | |
| 183 | |
| 184 | |
| 185 | class TestExtractTextFromXml: |
| 186 | def test_basic_xml(self): |
| 187 | xml = b"<root><p>The ayes have it.</p><p>So ordered.</p></root>" |
| 188 | text = _extract_text_from_xml(xml) |
| 189 | assert "ayes have it" in text |
| 190 | assert "So ordered" in text |
| 191 | |
| 192 | def test_nested_elements(self): |
| 193 | xml = b"<a><b><c>deep text</c></b></a>" |
| 194 | assert "deep text" in _extract_text_from_xml(xml) |
| 195 | |
| 196 | def test_html_tags_stripped(self): |
| 197 | htm = b"<p><b>Mr. JONES.</b> Without objection, so ordered.</p>" |
| 198 | text = _extract_text_from_xml(htm) |
| 199 | assert "without objection" in text.lower() |
| 200 | assert "<p>" not in text |
| 201 | assert "<b>" not in text |
| 202 | |
| 203 | def test_html_entities_unescaped(self): |
| 204 | htm = b"<p>The ayes have it. The amendment is agreed to.</p>" |
| 205 | text = _extract_text_from_xml(htm) |
| 206 | assert "ayes have it" in text |
| 207 | |
| 208 | def test_empty_bytes(self): |
| 209 | assert _extract_text_from_xml(b"") == "" |
| 210 | |
| 211 | def test_malformed_html_decoded(self): |
| 212 | raw = b"<broken>The noes have it.<br>" |
| 213 | text = _extract_text_from_xml(raw) |
| 214 | assert "noes have it" in text |
| 215 | |
| 216 | |
| 217 | |
| 218 | class TestInferChamber: |
| 219 | def test_house_filename(self): |
| 220 | assert _infer_chamber("CREC-2026-06-24-pt1-PgH1.xml") == "House" |
| 221 | |
| 222 | def test_senate_filename(self): |
| 223 | assert _infer_chamber("CREC-2026-06-24-pt1-PgS100.xml") == "Senate" |
| 224 | |
| 225 | def test_digest_filename(self): |
| 226 | assert _infer_chamber("CREC-2026-06-24-pt1-PgD1.xml") == "Both" |
| 227 | |
| 228 | def test_unknown_filename(self): |
| 229 | assert _infer_chamber("CREC-2026-06-24-extensions.xml") == "Unknown" |
| 230 | |
| 231 | |
| 232 | |
| 233 | class TestContextSnippet: |
| 234 | def test_returns_text_around_match(self): |
| 235 | text = "The Presiding Officer called for order. The ayes have it. The amendment is agreed to." |
| 236 | m = _VOICE_VOTE.search(text) |
| 237 | snippet = _context_snippet(text, m, window=40) |
| 238 | assert "ayes have it" in snippet |
| 239 | |
| 240 | def test_does_not_exceed_text_bounds(self): |
| 241 | text = "The ayes have it." |
| 242 | m = _VOICE_VOTE.search(text) |
| 243 | snippet = _context_snippet(text, m, window=200) |
| 244 | assert snippet in text or set(snippet) <= set(text + " ") |
| 245 | |
| 246 | def test_strips_newlines(self): |
| 247 | text = "Motion to recommit\nwas offered." |
| 248 | m = _MTR.search(text) |
| 249 | snippet = _context_snippet(text, m, window=60) |
| 250 | assert "\n" not in snippet |
| 251 | |
| 252 | |
| 253 | |
| 254 | class TestDetectEvents: |
| 255 | BASE_URL = "https://www.govinfo.gov/app/details/CREC-2026-06-24" |
| 256 | |
| 257 | def _events(self, text, chamber="Senate"): |
| 258 | return list( |
| 259 | _detect_events( |
| 260 | text, |
| 261 | chamber=chamber, |
| 262 | date_iso="2026-06-24", |
| 263 | source_url=self.BASE_URL, |
| 264 | filename="test-section.xml", |
| 265 | ) |
| 266 | ) |
| 267 | |
| 268 | def test_voice_vote_detected(self): |
| 269 | events = self._events("The PRESIDING OFFICER. The ayes have it.") |
| 270 | assert any(e["kind"] == "voice_vote" for e in events) |
| 271 | |
| 272 | def test_uc_detected(self): |
| 273 | events = self._events("Without objection, so ordered.") |
| 274 | assert any(e["kind"] == "unanimous_consent" for e in events) |
| 275 | |
| 276 | def test_cloture_detected(self): |
| 277 | events = self._events("Cloture was invoked on the nomination by 62-38.") |
| 278 | assert any(e["kind"] == "cloture" for e in events) |
| 279 | |
| 280 | def test_suspension_detected(self): |
| 281 | events = self._events("On motion to suspend the rules and pass H.R. 99.") |
| 282 | assert any(e["kind"] == "suspension" for e in events) |
| 283 | |
| 284 | def test_mtr_detected(self): |
| 285 | events = self._events("Mr. SMITH offered a motion to recommit with instructions.") |
| 286 | assert any(e["kind"] == "mtr" for e in events) |
| 287 | |
| 288 | def test_discharge_detected(self): |
| 289 | events = self._events("A discharge petition was filed for H.R. 10.") |
| 290 | assert any(e["kind"] == "discharge" for e in events) |
| 291 | |
| 292 | def test_members_jsonb_is_none(self): |
| 293 | """Voice votes have no individual member record - members_jsonb must be null.""" |
| 294 | events = self._events("The ayes have it. The resolution is agreed to.") |
| 295 | vv = [e for e in events if e["kind"] == "voice_vote"] |
| 296 | assert len(vv) >= 1 |
| 297 | for e in vv: |
| 298 | assert e["members_jsonb"] is None, ( |
| 299 | "Voice-vote events must have members_jsonb=None " |
| 300 | "(no individual record exists - that absence is itself information)" |
| 301 | ) |
| 302 | |
| 303 | def test_source_url_is_gov(self): |
| 304 | events = self._events("The ayes have it.") |
| 305 | for e in events: |
| 306 | assert is_allowlisted(e["source_url"]), ( |
| 307 | f"source_url {e['source_url']!r} is not a .gov URL" |
| 308 | ) |
| 309 | |
| 310 | def test_chamber_propagated(self): |
| 311 | events = self._events("The ayes have it.", chamber="House") |
| 312 | vv = [e for e in events if e["kind"] == "voice_vote"] |
| 313 | assert all(e["chamber"] == "House" for e in vv) |
| 314 | |
| 315 | def test_date_propagated(self): |
| 316 | events = self._events("The noes have it.") |
| 317 | for e in events: |
| 318 | assert e["date"] == "2026-06-24" |
| 319 | |
| 320 | def test_external_id_unique_per_match(self): |
| 321 | text = "The ayes have it. The ayes have it. The noes have it." |
| 322 | events = self._events(text) |
| 323 | vv_ids = [e["external_id"] for e in events if e["kind"] == "voice_vote"] |
| 324 | assert len(vv_ids) == len(set(vv_ids)), "external_ids must be unique" |
| 325 | |
| 326 | def test_empty_text_yields_no_events(self): |
| 327 | assert self._events("") == [] |
| 328 | |
| 329 | def test_unrelated_text_yields_no_events(self): |
| 330 | text = "Mr. SMITH. I yield back the balance of my time." |
| 331 | assert self._events(text) == [] |
| 332 | |
| 333 | |
| 334 | |
| 335 | class TestSourceUrlIntegrity: |
| 336 | """All source_urls produced by the CREC poller must be .gov allowlisted.""" |
| 337 | |
| 338 | def test_govinfo_page_url_is_gov(self): |
| 339 | from ingest.pollers.crec import _govinfo_page_url |
| 340 | from datetime import datetime |
| 341 | url = _govinfo_page_url(datetime(2026, 6, 24)) |
| 342 | assert "govinfo.gov" in url |
| 343 | assert is_allowlisted(url) |
| 344 | |
| 345 | def test_govinfo_zip_url_is_gov(self): |
| 346 | from ingest.pollers.crec import _govinfo_url |
| 347 | from datetime import datetime |
| 348 | url = _govinfo_url(datetime(2026, 6, 24)) |
| 349 | assert "govinfo.gov" in url |
| 350 | assert is_allowlisted(url) |
| 351 | |
| 352 | |
| 353 | |
| 354 | class TestIdempotency: |
| 355 | """Re-processing the same events must not create duplicate rows.""" |
| 356 | |
| 357 | SOURCE_URL = "https://www.govinfo.gov/app/details/CREC-2026-06-24" |
| 358 | |
| 359 | def test_no_duplicate_rows_on_second_pass(self, temp_db): |
| 360 | """ |
| 361 | Call _detect_events twice with the same text and simulate two DB |
| 362 | insert passes; the second pass must yield 0 new stored rows. |
| 363 | """ |
| 364 | text = "The ayes have it. Without objection, so ordered." |
| 365 | events = list( |
| 366 | _detect_events(text, "Senate", "2026-06-24", self.SOURCE_URL, "test.xml") |
| 367 | ) |
| 368 | assert len(events) > 0 |
| 369 | |
| 370 | from ingest.change_detection import is_new_event, mark_seen |
| 371 | |
| 372 | for evt in events: |
| 373 | mark_seen(temp_db, "crec", evt["external_id"], evt["update_date"]) |
| 374 | |
| 375 | new = [ |
| 376 | e for e in events |
| 377 | if is_new_event(temp_db, "crec", e["external_id"], e["update_date"]) |
| 378 | ] |
| 379 | assert new == [], f"Second pass should be empty; got {new}" |