Coverage for tests/test_rules.py: 100%
104 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-22 22:47 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-22 22:47 +0000
1"""Tests the rules module."""
3from __future__ import annotations
5import pathlib
6import re
8import pytest
10from fjc_biographies import rules
13def make(**cells: str) -> rules.Rule:
14 row = {'ignorecase': 'TRUE', 'dotall': 'FALSE', 'value': '', **cells}
15 return rules.Rule.create(row)
18def apply(rulebook: list[rules.Rule], sections: dict[str, str]) -> dict:
19 return rules.Rulebook(rules = rulebook).apply(sections)
22def test_flag_count_and_matches() -> None:
23 book = [
24 make(target = 'opinion', variable = 'habeas', kind = 'flag', pattern = 'habeas'),
25 make(target = 'opinion', variable = 'jury', kind = 'flag', pattern = 'jury'),
26 make(target = 'opinion', variable = 'cites', kind = 'count', pattern = r'\d+ U\.S\. \d+'),
27 make(target = 'opinion', variable = 'refs', kind = 'matches', pattern = r'\d+ U\.S\. \d+'),
28 make(target = 'opinion', variable = 'refs', kind = 'matches', pattern = r'\d+ F\.3d \d+')]
29 found = apply(book, {'opinion': 'A HABEAS case. See 1 U.S. 2, 3 U.S. 4, and 5 F.3d 6.'})
30 assert found == {
31 'habeas': True, 'jury': False, 'cites': 2,
32 'refs': ['1 U.S. 2', '3 U.S. 4', '5 F.3d 6']}
35def test_missing_targets_give_empty_values() -> None:
36 book = [
37 make(target = 'notice', variable = 'unpublished', kind = 'flag', pattern = 'x'),
38 make(target = 'notice', variable = 'n', kind = 'count', pattern = 'x'),
39 make(target = 'notice', variable = 'm', kind = 'matches', pattern = 'x'),
40 make(target = 'notice', variable = 'l', kind = 'label', pattern = 'x', value = '1'),
41 make(target = 'notice', variable = 's', kind = 'section', pattern = 'x'),
42 make(target = 'notice', variable = 'names', kind = 'names', pattern = ',')]
43 assert apply(book, {}) == {
44 'unpublished': False, 'n': 0, 'm': [], 'l': None, 's': None, 'names': []}
47def test_label_uses_first_match_and_converts_numbers() -> None:
48 book = [
49 make(target = 'court', variable = 'court_num', kind = 'label', pattern = 'FIRST CIRCUIT', value = '1'),
50 make(target = 'court', variable = 'court_num', kind = 'label', pattern = 'CIRCUIT', value = 'other')]
51 assert apply(book, {'court': 'Court of Appeals for the First Circuit'})['court_num'] == 1
52 assert apply(book, {'court': 'Second Circuit'})['court_num'] == 'other'
53 assert apply(book, {'court': 'District Court'})['court_num'] is None
56def test_label_searches_every_target() -> None:
57 rule = make(
58 target = 'history, party1', variable = 'agency', kind = 'label',
59 pattern = 'LABOR RELATIONS', value = 'NLRB')
60 assert apply([rule], {'party1': 'National Labor Relations Board'}) == {'agency': 'NLRB'}
63def test_split_section_and_excerpts_make_new_sections() -> None:
64 book = [
65 make(target = 'text', variable = 'header, opinion', kind = 'split', pattern = r'\nOPINION\n'),
66 make(target = 'header', variable = 'judges', kind = 'section', pattern = r'JUDGES:[^\n]*'),
67 make(target = 'header', variable = 'dates', kind = 'excerpts', pattern = r'^\w+ \d+, \d{4}.*$',
68 dotall = 'FALSE'),
69 make(target = 'judges', variable = 'panel', kind = 'names', pattern = r'\bJUDGES\b|:|,|\bAND\b')]
70 rulebook = rules.Rulebook(rules = book)
71 sections = {'text': 'A v. B\nJudges: Smith, Jones and Brown\nMarch 3, 2009, Argued\nOPINION\nWe affirm.'}
72 found = rulebook.apply(sections)
73 assert sections['opinion'] == 'We affirm.'
74 assert found['judges'] == 'Judges: Smith, Jones and Brown'
75 assert found['panel'] == ['SMITH', 'JONES', 'BROWN']
76 assert rulebook.sections == ['header', 'opinion', 'judges', 'dates']
77 assert rulebook.variables == ['header', 'opinion', 'judges', 'dates', 'panel']
80def test_dates_excerpts_use_multiline_anchors() -> None:
81 rule = rules.Rule.create({
82 'target': 'header', 'variable': 'dates', 'kind': 'excerpts',
83 'pattern': r'(?<=\n)\w+ \d+, \d{4}.*?(?=\n)', 'ignorecase': 'TRUE',
84 'dotall': 'FALSE'})
85 found = apply([rule], {'header': '\nMarch 3, 2009, Argued\nJune 1, 2009, Decided\n'})
86 assert found['dates'] == 'March 3, 2009, Argued\nJune 1, 2009, Decided'
89def test_split_without_match_keeps_everything_first() -> None:
90 rule = make(target = 'party', variable = 'party1, party2', kind = 'split', pattern = r'\Wv\.\W')
91 assert apply([rule], {'party': 'In re Smith'}) == {'party1': 'In re Smith', 'party2': None}
92 assert apply([rule], {'party': 'Smith v. Jones'}) == {'party1': 'Smith', 'party2': 'Jones'}
95def test_remove_changes_later_rules_only() -> None:
96 book = [
97 make(target = 'party1', variable = 'defendant', kind = 'flag', pattern = 'DEFENDANT'),
98 make(target = 'party1', kind = 'remove', variable = '', pattern = 'DEFENDANT'),
99 make(target = 'party1', variable = 'still', kind = 'flag', pattern = 'DEFENDANT')]
100 assert apply(book, {'party1': 'Doe, Defendant'}) == {'defendant': True, 'still': False}
103def test_split_names() -> None:
104 separators = re.compile(r"\b(?:BEFORE|CIRCUIT|JUDGES?|AND)\b|[,:]")
105 names = rules.split_names(
106 "Before: O'Scannlain, Kirby, and HOLLAND, Circuit Judges.\nBybee", separators)
107 assert names == ['OSCANNLAIN', 'KIRBY', 'HOLLAND', 'BYBEE']
110def test_names_ignores_groups_in_pattern() -> None:
111 rule = make(target = 'j', variable = 'names', kind = 'names', pattern = r'(,)|(\bAND\b)')
112 assert apply([rule], {'j': 'Smith, Jones and Brown'}) == {'names': ['SMITH', 'JONES', 'BROWN']}
115@pytest.mark.parametrize(('cells', 'message'), [
116 ({'kind': 'nope'}, 'is not one of'),
117 ({'pattern': '('}, 'is not valid'),
118 ({'pattern': ''}, 'pattern is blank'),
119 ({'target': ''}, 'target is blank'),
120 ({'kind': 'split', 'variable': 'one'}, 'needs two variables'),
121 ({'ignorecase': 'maybe'}, 'is not TRUE or FALSE')])
122def test_invalid_rules(cells: dict[str, str], message: str) -> None:
123 row = {'target': 'opinion', 'variable': 'x', 'kind': 'flag', 'pattern': 'x',
124 'ignorecase': 'TRUE', 'dotall': 'FALSE', **cells}
125 with pytest.raises(ValueError, match = message):
126 rules.Rule.create(row)
129def test_load_csv_from_excel(tmp_path: pathlib.Path) -> None:
130 path = tmp_path / 'mine.csv'
131 path.write_text(
132 'Target,Variable,Kind,Pattern,Value,IgnoreCase,DotAll,Note\n'
133 'opinion,my_flag,flag,"a, b",,true,false,anything\n\n',
134 encoding = 'utf-8')
135 rulebook = rules.Rulebook.load(path)
136 assert len(rulebook) == 1
137 assert rulebook.rules[0].pattern.pattern == 'a, b'
138 assert rulebook.rules[0].note == 'anything'
141def test_load_reports_file_and_row(tmp_path: pathlib.Path) -> None:
142 path = tmp_path / 'bad.csv'
143 path.write_text(
144 'target,variable,kind,pattern,ignorecase,dotall\n'
145 'opinion,ok,flag,x,TRUE,FALSE\n'
146 'opinion,bad,flag,(,TRUE,FALSE\n', encoding = 'utf-8')
147 with pytest.raises(ValueError, match = r'bad\.csv, row 3'):
148 rules.Rulebook.load(path)
149 missing = tmp_path / 'missing.csv'
150 missing.write_text('target,variable\n', encoding = 'utf-8')
151 with pytest.raises(ValueError, match = 'missing the columns'):
152 rules.Rulebook.load(missing)
156def test_find_instructions() -> None:
157 assert [p.name for p in rules.find_instructions('judges')] == ['judges.csv']
158 assert rules.find_instructions('judges.csv')[0].parent.name == 'instructions'
159 with pytest.raises(FileNotFoundError, match = r"built-in rulebook \(\['judges'\]\)"):
160 rules.find_instructions('no_such_rules')
163def test_folder_of_rules(tmp_path: pathlib.Path) -> None:
164 for name, variable in (('b.csv', 'second'), ('a.csv', 'first')):
165 (tmp_path / name).write_text(
166 f'target,variable,kind,pattern,ignorecase,dotall\ncareer,{variable},flag,x,TRUE,FALSE\n',
167 encoding = 'utf-8')
168 assert rules.Rulebook.load(tmp_path).variables == ['first', 'second']
169 empty = tmp_path / 'empty'
170 empty.mkdir()
171 with pytest.raises(FileNotFoundError, match = 'no CSV files'):
172 rules.find_instructions(empty)
175def test_files_named_twice_are_read_once(tmp_path: pathlib.Path) -> None:
176 built_in = rules.find_instructions('judges')[0]
177 both = rules.Rulebook.load('judges', built_in)
178 assert len(both) == len(rules.Rulebook.load('judges'))
179 assert both.name == 'judges'
182def test_create() -> None:
183 book = rules.Rulebook.load('judges')
184 assert rules.Rulebook.create(book) is book
185 assert len(rules.Rulebook.create('judges')) == len(book)
188def test_judges_rulebook() -> None:
189 book = rules.Rulebook.load('judges')
190 assert book.targets == ['career']
191 assert book.variables == [
192 'prosecutor', 'public_defender', 'law_clerk', 'supreme_court_clerk',
193 'solicitor_general', 'law_professor']
194 career = '\n'.join([
195 'Law clerk, Hon. B. Justice, Supreme Court of the United States, 1970-1971',
196 'Assistant U.S. Attorney, Southern District of New York, 1972-1975',
197 'Commonwealth\'s Attorney, Fairfax County, Virginia, 1976-1980'])
198 assert book.apply({'career': career}) == {
199 'prosecutor': True, 'public_defender': False, 'law_clerk': True,
200 'supreme_court_clerk': True, 'solicitor_general': False,
201 'law_professor': False}
202 # A clerkship and the Supreme Court in different positions are not a
203 # clerkship at the Supreme Court.
204 apart = 'Law clerk, U.S. District Court, 1970\nReporter, Supreme Court of the United States, 1971'
205 assert not book.apply({'career': apart})['supreme_court_clerk']