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

1"""Tests the rules module.""" 

2 

3from __future__ import annotations 

4 

5import pathlib 

6import re 

7 

8import pytest 

9 

10from fjc_biographies import rules 

11 

12 

13def make(**cells: str) -> rules.Rule: 

14 row = {'ignorecase': 'TRUE', 'dotall': 'FALSE', 'value': '', **cells} 

15 return rules.Rule.create(row) 

16 

17 

18def apply(rulebook: list[rules.Rule], sections: dict[str, str]) -> dict: 

19 return rules.Rulebook(rules = rulebook).apply(sections) 

20 

21 

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']} 

33 

34 

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': []} 

45 

46 

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 

54 

55 

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'} 

61 

62 

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'] 

78 

79 

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' 

87 

88 

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'} 

93 

94 

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} 

101 

102 

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'] 

108 

109 

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']} 

113 

114 

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) 

127 

128 

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' 

139 

140 

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) 

153 

154 

155 

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

161 

162 

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) 

173 

174 

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' 

180 

181 

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) 

186 

187 

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']