| 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 | ) |