● Zion Boggan
repos/Docket/services/ingest/tests/test_bills_diff.py
zionboggan.com ↗
377 lines · python
History for this file →
1
"""
2
Unit tests for the bill diff classifier and rider detection regex.
3
 
4
All tests are pure-Python / no network / no DB.
5
"""
6
 
7
import pytest
8
 
9
from ingest.diff import (
10
    Section,
11
    classify_change,
12
    align_sections,
13
    _fuzzy_score,
14
    parse_sections,
15
    _make_snippet,
16
)
17
from ingest.rider_detect import (
18
    is_rider_signal,
19
    is_rider_signal_strong,
20
    matched_signals,
21
)
22
 
23
 
24
 
25
class TestClassifyChange:
26
    def test_added_when_old_empty(self):
27
        assert classify_change(None, "new text here") == "added"
28
 
29
    def test_added_when_old_blank(self):
30
        assert classify_change("", "new text here") == "added"
31
 
32
    def test_removed_when_new_empty(self):
33
        assert classify_change("old text", None) == "removed"
34
 
35
    def test_removed_when_new_blank(self):
36
        assert classify_change("old text", "") == "removed"
37
 
38
    def test_amended_when_both_differ(self):
39
        assert classify_change("original language", "revised language") == "amended"
40
 
41
    def test_no_change_when_identical(self):
42
        assert classify_change("same text", "same text") is None
43
 
44
    def test_no_change_whitespace_stripped(self):
45
        assert classify_change("  same  ", "same") is None
46
 
47
    def test_added_short(self):
48
        assert classify_change("", "x") == "added"
49
 
50
    def test_removed_short(self):
51
        assert classify_change("x", "") == "removed"
52
 
53
 
54
 
55
def _sec(sec_id="", enum="", header="", text=""):
56
    return Section(sec_id=sec_id, enum=enum, header=header, text=text)
57
 
58
 
59
class TestAlignSections:
60
    def test_exact_id_match(self):
61
        old = [_sec(sec_id="H001", header="Short Title", text="old")]
62
        new = [_sec(sec_id="H001", header="Short Title", text="new")]
63
        pairs = align_sections(old, new)
64
        assert len(pairs) == 1
65
        assert pairs[0][0] is old[0]
66
        assert pairs[0][1] is new[0]
67
 
68
    def test_added_section_no_id(self):
69
        old = [_sec(sec_id="H001", header="Definitions", text="some defs")]
70
        new = [
71
            _sec(sec_id="H001", header="Definitions", text="some defs"),
72
            _sec(sec_id="",     header="New Provision", text="brand new"),
73
        ]
74
        pairs = align_sections(old, new)
75
        matched = [(o, n) for o, n in pairs if o and n]
76
        added   = [(o, n) for o, n in pairs if o is None]
77
        assert len(matched) == 1
78
        assert len(added) == 1
79
 
80
    def test_removed_section(self):
81
        old = [
82
            _sec(sec_id="H001", header="Authorization", text="text"),
83
            _sec(sec_id="H002", header="Definitions",   text="defs"),
84
        ]
85
        new = [_sec(sec_id="H001", header="Authorization", text="text")]
86
        pairs = align_sections(old, new)
87
        removed = [(o, n) for o, n in pairs if n is None]
88
        assert len(removed) == 1
89
        assert removed[0][0].sec_id == "H002"
90
 
91
    def test_fuzzy_header_match(self):
92
        old = [_sec(sec_id="",  enum="1.", header="Authorization of Appropriations")]
93
        new = [_sec(sec_id="",  enum="1.", header="Authorization of Appropriationz")]
94
        pairs = align_sections(old, new)
95
        matched = [(o, n) for o, n in pairs if o and n]
96
        assert len(matched) == 1
97
 
98
    def test_fuzzy_below_threshold_not_matched(self):
99
        old = [_sec(sec_id="", enum="1.", header="Authorization of Appropriations")]
100
        new = [_sec(sec_id="", enum="9.", header="Entirely Different Section")]
101
        pairs = align_sections(old, new)
102
        added   = [(o, n) for o, n in pairs if o is None]
103
        removed = [(o, n) for o, n in pairs if n is None]
104
        assert len(added) == 1
105
        assert len(removed) == 1
106
 
107
    def test_empty_old(self):
108
        new = [_sec(sec_id="H001", text="all new")]
109
        pairs = align_sections([], new)
110
        added = [(o, n) for o, n in pairs if o is None]
111
        assert len(added) == 1
112
 
113
    def test_empty_new(self):
114
        old = [_sec(sec_id="H001", text="was here")]
115
        pairs = align_sections(old, [])
116
        removed = [(o, n) for o, n in pairs if n is None]
117
        assert len(removed) == 1
118
 
119
    def test_both_empty(self):
120
        assert align_sections([], []) == []
121
 
122
 
123
 
124
class TestMakeSnippet:
125
    def test_added_snippet_length(self):
126
        long_text = "X" * 1000
127
        s = _make_snippet(None, long_text, "added")
128
        assert len(s) <= 510
129
        assert s.endswith("…")
130
 
131
    def test_removed_snippet_short(self):
132
        s = _make_snippet("short", None, "removed")
133
        assert s == "short"
134
 
135
    def test_amended_has_diff_markers(self):
136
        s = _make_snippet("line one\nline two", "line one\nline three", "amended")
137
        assert "+" in s or "-" in s or "@@" in s
138
 
139
 
140
 
141
SIMPLE_BILL_XML = """<?xml version="1.0"?>
142
<bill>
143
  <body>
144
    <section id="H001">
145
      <enum>1.</enum>
146
      <header>Short Title</header>
147
      <text>This Act may be cited as the Test Act.</text>
148
    </section>
149
    <section id="H002">
150
      <enum>2.</enum>
151
      <header>Definitions</header>
152
      <text>In this Act-</text>
153
      <subsection>
154
        <enum>(a)</enum>
155
        <header>Term</header>
156
        <text>The term "widget" means any gizmo.</text>
157
      </subsection>
158
    </section>
159
  </body>
160
</bill>
161
"""
162
 
163
NAMESPACED_BILL_XML = """<?xml version="1.0"?>
164
<bill xmlns:ns="http://xml.house.gov/schemas/uslm/1.0">
165
  <ns:body>
166
    <ns:section id="S001">
167
      <ns:enum>1.</ns:enum>
168
      <ns:header>Purpose</ns:header>
169
      <ns:text>The purpose of this Act is to test parsing.</ns:text>
170
    </ns:section>
171
  </ns:body>
172
</bill>
173
"""
174
 
175
 
176
class TestParseSections:
177
    def test_simple_xml_two_sections(self):
178
        sections = parse_sections(SIMPLE_BILL_XML)
179
        assert len(sections) == 2
180
        assert sections[0].sec_id == "H001"
181
        assert "Short Title" in sections[0].header
182
        assert "Test Act" in sections[0].text
183
 
184
    def test_section_enum_captured(self):
185
        sections = parse_sections(SIMPLE_BILL_XML)
186
        assert sections[1].enum == "2."
187
 
188
    def test_subsection_text_flattened(self):
189
        sections = parse_sections(SIMPLE_BILL_XML)
190
        assert "widget" in sections[1].text or "widget" in sections[1].text
191
 
192
    def test_invalid_xml_returns_empty(self):
193
        sections = parse_sections("<not valid xml <<>>")
194
        assert sections == []
195
 
196
    def test_empty_xml_returns_empty(self):
197
        sections = parse_sections("")
198
        assert sections == []
199
 
200
    def test_namespaced_xml_parsed(self):
201
        sections = parse_sections(NAMESPACED_BILL_XML)
202
        assert isinstance(sections, list)
203
 
204
 
205
 
206
class TestRiderDetect:
207
    def test_notwithstanding_triggers(self):
208
        assert is_rider_signal(
209
            "Notwithstanding any other provision of law, the Secretary shall..."
210
        )
211
 
212
    def test_provided_comma_triggers(self):
213
        assert is_rider_signal(
214
            "Provided, That no funds shall be used for..."
215
        )
216
 
217
    def test_provided_further_triggers(self):
218
        assert is_rider_signal(
219
            "Provided Further, That the Director may waive..."
220
        )
221
 
222
    def test_is_amended_triggers(self):
223
        assert is_rider_signal(
224
            "Section 401(a) of the XYZ Act Is Amended by inserting..."
225
        )
226
 
227
    def test_are_authorized_triggers(self):
228
        assert is_rider_signal(
229
            "There Are Authorized to be appropriated $5,000,000 for each..."
230
        )
231
 
232
    def test_unless_triggers(self):
233
        assert is_rider_signal(
234
            "Unless the Secretary determines otherwise, the grant shall..."
235
        )
236
 
237
    def test_until_triggers(self):
238
        assert is_rider_signal(
239
            "No action shall be taken Until the report is submitted."
240
        )
241
 
242
    def test_determines_triggers(self):
243
        assert is_rider_signal(
244
            "The Administrator Determines that the standard is met."
245
        )
246
 
247
    def test_in_general_triggers(self):
248
        assert is_rider_signal("In General.  -  The Secretary shall...")
249
 
250
    def test_clean_text_no_trigger(self):
251
        assert not is_rider_signal(
252
            "This Act establishes a new national park in Colorado."
253
        )
254
 
255
    def test_case_insensitive_notwithstanding(self):
256
        assert is_rider_signal("NOTWITHSTANDING section 4 of the prior Act,")
257
 
258
    def test_case_insensitive_provided(self):
259
        assert is_rider_signal("provided, that all funds shall be...")
260
 
261
    def test_strong_check_no_funds(self):
262
        assert is_rider_signal_strong(
263
            "None of the funds appropriated under this Act shall be..."
264
        )
265
 
266
    def test_strong_check_no_funds_variant(self):
267
        assert is_rider_signal_strong(
268
            "No funds appropriated herein may be used to..."
269
        )
270
 
271
    def test_matched_signals_returns_list(self):
272
        signals = matched_signals(
273
            "Notwithstanding other provisions, Provided, That the Secretary..."
274
        )
275
        assert len(signals) >= 2
276
 
277
    def test_no_false_positive_normal_text(self):
278
        text = (
279
            "The Secretary of Defense shall submit a report to Congress "
280
            "within 90 days of the date of enactment of this Act "
281
            "describing the status of the program."
282
        )
283
        assert not is_rider_signal(text)
284
 
285
    def test_notwithstanding_inline(self):
286
        text = (
287
            "Notwithstanding section 1341 of title 31, United States Code, "
288
            "the Secretary may transfer amounts between appropriation accounts."
289
        )
290
        assert is_rider_signal(text)
291
 
292
    def test_provided_further_complex(self):
293
        text = (
294
            "Provided, That the amount made available shall be $10,000,000: "
295
            "Provided Further, That not less than $2,000,000 shall be for..."
296
        )
297
        assert is_rider_signal(text)
298
        signals = matched_signals(text)
299
        assert len(signals) >= 2
300
 
301
    def test_empty_text_no_trigger(self):
302
        assert not is_rider_signal("")
303
        assert not is_rider_signal_strong("")
304
 
305
 
306
 
307
OLD_BILL_XML = """<?xml version="1.0"?>
308
<bill>
309
  <body>
310
    <section id="H001">
311
      <enum>1.</enum>
312
      <header>Short Title</header>
313
      <text>This Act may be cited as the Widget Act of 2024.</text>
314
    </section>
315
    <section id="H002">
316
      <enum>2.</enum>
317
      <header>Authorization</header>
318
      <text>There is authorized to be appropriated $5,000,000.</text>
319
    </section>
320
  </body>
321
</bill>
322
"""
323
 
324
NEW_BILL_XML = """<?xml version="1.0"?>
325
<bill>
326
  <body>
327
    <section id="H001">
328
      <enum>1.</enum>
329
      <header>Short Title</header>
330
      <text>This Act may be cited as the Widget Act of 2025.</text>
331
    </section>
332
    <section id="H002">
333
      <enum>2.</enum>
334
      <header>Authorization</header>
335
      <text>There is authorized to be appropriated $10,000,000.</text>
336
    </section>
337
    <section id="H003">
338
      <enum>3.</enum>
339
      <header>Notwithstanding Provision</header>
340
      <text>Notwithstanding any other provision of law, no funds appropriated
341
            under this Act shall be used to implement section 404.</text>
342
    </section>
343
  </body>
344
</bill>
345
"""
346
 
347
 
348
class TestFullDiffPipeline:
349
    def test_changed_section_is_amended(self):
350
        old_secs = parse_sections(OLD_BILL_XML)
351
        new_secs = parse_sections(NEW_BILL_XML)
352
        pairs = align_sections(old_secs, new_secs)
353
 
354
        changes = {}
355
        for o, n in pairs:
356
            change = classify_change(
357
                o.text if o else None,
358
                n.text if n else None,
359
            )
360
            sec = (n or o)
361
            if change:
362
                changes[sec.sec_id] = change
363
 
364
        assert changes.get("H001") == "amended"
365
        assert changes.get("H002") == "amended"
366
        assert changes.get("H003") == "added"
367
 
368
    def test_added_section_rider_flag(self):
369
        old_secs = parse_sections(OLD_BILL_XML)
370
        new_secs = parse_sections(NEW_BILL_XML)
371
        pairs    = align_sections(old_secs, new_secs)
372
 
373
        for o, n in pairs:
374
            if o is None and n is not None:
375
                assert is_rider_signal(n.text), (
376
                    f"Expected rider signal in added section: {n.text!r}"
377
                )