● Zion Boggan
repos/Docket/services/ingest/tests/test_crec_voice_vote.py
zionboggan.com ↗
379 lines · python
History for this file →
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.&nbsp; The amendment is agreed&nbsp;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}"