Coverage for tests/test_scores.py: 100%

135 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-22 22:47 +0000

1"""Tests for adding Judicial Common Space and Martin-Quinn scores.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import pathlib 

7from typing import Any 

8 

9import judicial_common_space as jcs 

10import pandas as pd 

11import pytest 

12from conftest import SERVICE, tables 

13 

14import fjc_biographies 

15from fjc_biographies import __main__ as cli 

16 

17MQ_COLUMNS = ['mq_first', 'mq_last', 'mq_mean', 'mq_terms'] 

18# Invented Martin-Quinn scores in the layout of "justices.csv": John Marsh 

19# (the invented justice, 112) in three terms, and a justice the FJC lacks. 

20MARTIN_QUINN = ( 

21 'term,justice,justiceName,post_mn,post_sd\n' 

22 '1803,1,JMarsh,1.5,0.2\n' 

23 '1801,1,JMarsh,0.5,0.2\n' 

24 '1802,1,JMarsh,1.0,0.2\n' 

25 '1802,2,XNobody,-2.0,0.3\n') 

26 

27 

28@pytest.fixture 

29def martin_quinn(tmp_path: pathlib.Path) -> pathlib.Path: 

30 """Returns the path of a file of invented Martin-Quinn scores.""" 

31 path = tmp_path / 'justices.csv' 

32 path.write_text(MARTIN_QUINN, encoding = 'utf-8') 

33 return path 

34 

35 

36@pytest.fixture 

37def roster() -> pd.DataFrame: 

38 """Returns the roster of the invented judges.""" 

39 files = tables() 

40 return fjc_biographies.build_roster( 

41 files['service'], files['demographics'], files['career']) 

42 

43 

44@pytest.fixture 

45def computed(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: 

46 """Replaces judicial-common-space's computation and its downloads.""" 

47 calls: dict[str, Any] = {'downloads': []} 

48 

49 def download(kinds: Any = None, folder: Any = None, *, overwrite: bool = False, **kwargs: Any) -> dict: 

50 calls['downloads'].append((kinds, overwrite)) 

51 if kinds in ('duty_stations', 'martin_quinn'): 

52 message = f'{kinds} cannot be reached' 

53 raise OSError(message) 

54 return {} 

55 

56 def compute(files: dict[str, pathlib.Path], **kwargs: Any) -> tuple[pd.DataFrame, pd.DataFrame]: 

57 calls['files'] = files 

58 calls['martin_quinn'] = kwargs.get('martin_quinn') 

59 # A score for each appointment but the second of judge 107, with 

60 # the numbers as judicial-common-space has them. 

61 scored = pd.DataFrame( 

62 [(nid, sequence, nid / 1000) for nid, sequence, *_ in SERVICE 

63 if (nid, sequence) != (107, 2)], 

64 columns = ['nid', 'sequence', 'jcs']) 

65 return scored, pd.DataFrame() 

66 

67 monkeypatch.setattr(jcs.sources, 'download', download) 

68 monkeypatch.setattr(jcs, 'compute', compute) 

69 return calls 

70 

71 

72def test_martin_quinn_terms(requests: list[str], martin_quinn: pathlib.Path) -> None: 

73 terms = fjc_biographies.martin_quinn_terms(martin_quinn = martin_quinn) 

74 assert len(terms) == 4 

75 marsh = terms[terms['justiceName'] == 'JMarsh'].sort_values('term') 

76 assert list(marsh['nid']) == [112] * 3 

77 assert list(marsh['sequence']) == [1] * 3 

78 assert list(marsh['martin_quinn']) == [0.5, 1.0, 1.5] 

79 # The scores are put on the scale of the Judicial Common Space too. 

80 assert marsh['jcs'].between(-1, 1).all() 

81 # A justice whom the FJC's files lack has no numbers. 

82 nobody = terms[terms['justiceName'] == 'XNobody'].iloc[0] 

83 assert pd.isna(nobody['nid']) and pd.isna(nobody['sequence']) 

84 assert terms['nid'].dtype == 'Int64' 

85 # The FJC's files are downloaded if they are not there. 

86 assert any(url.endswith('federal-judicial-service.csv') for url in requests) 

87 

88 

89def test_add_martin_quinn(requests: list[str], roster: pd.DataFrame, martin_quinn: pathlib.Path) -> None: 

90 scored = fjc_biographies.add_martin_quinn(roster, martin_quinn = martin_quinn) 

91 assert len(scored) == len(roster) 

92 columns = list(scored.columns) 

93 place = columns.index('senate_vote') + 1 

94 assert columns[place:place + 4] == MQ_COLUMNS 

95 marsh = scored[scored['nid'] == 112].iloc[0] 

96 # The first and last terms are by year, not by the file's order. 

97 assert (marsh['mq_first'], marsh['mq_last'], marsh['mq_mean'], marsh['mq_terms']) == (0.5, 1.5, 1.0, 3) 

98 # Only appointments to the Supreme Court have scores. 

99 assert scored.loc[scored['nid'] != 112, MQ_COLUMNS].isna().all(axis = None) 

100 assert scored['mq_terms'].dtype == 'Int64' 

101 assert scored['mq_mean'].dtype == 'Float64' 

102 # The roster is not changed, and adding the scores again replaces them. 

103 assert 'mq_first' not in roster.columns 

104 again = fjc_biographies.add_martin_quinn(scored, martin_quinn = martin_quinn) 

105 assert list(again.columns) == columns 

106 

107 

108def test_martin_quinn_by_judge(requests: list[str], roster: pd.DataFrame, martin_quinn: pathlib.Path) -> None: 

109 scored = fjc_biographies.add_martin_quinn(roster, martin_quinn = martin_quinn) 

110 judges = fjc_biographies.roster_by_judge(scored).set_index('nid') 

111 # The scores describe an appointment, so they are numbered. 

112 assert judges.loc[112, 'mq_mean_1'] == 1.0 

113 assert 'mq_mean' not in judges.columns 

114 assert pd.isna(judges.loc[107, 'mq_mean_2']) 

115 

116 

117def test_martin_quinn_file_is_needed( 

118 requests: list[str], 

119 roster: pd.DataFrame, 

120 no_score_downloads: list[Any], 

121 caplog: pytest.LogCaptureFixture) -> None: 

122 # Without a file or a download, the error says how to get the file. 

123 with pytest.raises(RuntimeError, match = 'justices.csv'): 

124 fjc_biographies.add_martin_quinn(roster) 

125 assert no_score_downloads[-1][0] == 'martin_quinn' 

126 with pytest.raises(FileNotFoundError, match = 'no file of Martin-Quinn'): 

127 fjc_biographies.add_martin_quinn(roster, martin_quinn = 'no_such_file.csv') 

128 # A copy that was downloaded before is used when the download fails. 

129 saved = jcs.sources.paths()['martin_quinn'] 

130 saved.parent.mkdir(parents = True) 

131 saved.write_text(MARTIN_QUINN, encoding = 'utf-8') 

132 with caplog.at_level(logging.WARNING): 

133 scored = fjc_biographies.add_martin_quinn(roster, update = True) 

134 assert 'copy' in caplog.text 

135 assert no_score_downloads[-1] == ('martin_quinn', None, True) 

136 assert scored.loc[scored['nid'] == 112, 'mq_terms'].iloc[0] == 3 

137 

138 

139def test_add_jcs(requests: list[str], roster: pd.DataFrame, computed: dict[str, Any], data_folder: pathlib.Path) -> None: 

140 scored = fjc_biographies.add_jcs(roster) 

141 # The scores are computed from the FJC's files that the roster was made 

142 # from, and the files of politics are downloaded if they are missing. 

143 assert calls_files(computed) == data_folder / 'raw' 

144 assert computed['downloads'][0] == (('congress', 'legislators_historical', 'legislators_current'), False) 

145 # Without duty stations or Martin-Quinn scores, the scores are computed 

146 # anyway (and the justices have none). 

147 assert [kinds for kinds, _ in computed['downloads']][1:] == ['duty_stations', 'martin_quinn'] 

148 assert computed['martin_quinn'] is None 

149 columns = list(scored.columns) 

150 assert columns[columns.index('senate_vote') + 1] == 'jcs' 

151 assert scored['jcs'].dtype == 'Float64' 

152 by_appointment = scored.set_index(['nid', 'sequence'])['jcs'] 

153 assert by_appointment[(101, 1)] == pytest.approx(0.101) 

154 assert by_appointment[(107, 1)] == pytest.approx(0.107) 

155 # An appointment without a score has a missing one. 

156 assert pd.isna(by_appointment[(107, 2)]) 

157 

158 

159def test_add_jcs_options( 

160 requests: list[str], 

161 roster: pd.DataFrame, 

162 computed: dict[str, Any], 

163 martin_quinn: pathlib.Path) -> None: 

164 fjc_biographies.add_jcs(roster, martin_quinn = martin_quinn, update = True) 

165 # A file of Martin-Quinn scores is passed on, and is not downloaded. 

166 assert computed['martin_quinn'] == martin_quinn 

167 assert [kinds for kinds, _ in computed['downloads']] == [ 

168 ('congress', 'legislators_historical', 'legislators_current'), 'duty_stations'] 

169 assert all(overwrite for _, overwrite in computed['downloads']) 

170 

171 

172def test_both_scores(requests: list[str], roster: pd.DataFrame, computed: dict[str, Any], martin_quinn: pathlib.Path) -> None: 

173 scored = fjc_biographies.add_martin_quinn( 

174 fjc_biographies.add_jcs(roster), martin_quinn = martin_quinn) 

175 columns = list(scored.columns) 

176 place = columns.index('senate_vote') + 1 

177 assert columns[place:place + 5] == ['jcs', *MQ_COLUMNS] 

178 

179 

180def test_scores_need_a_roster() -> None: 

181 table = pd.DataFrame({'nid': [101]}) 

182 with pytest.raises(KeyError, match = 'sequence'): 

183 fjc_biographies.add_jcs(table) 

184 with pytest.raises(KeyError, match = 'build_roster'): 

185 fjc_biographies.add_martin_quinn(table) 

186 

187 

188def test_run_with_scores( 

189 requests: list[str], 

190 computed: dict[str, Any], 

191 martin_quinn: pathlib.Path, 

192 tmp_path: pathlib.Path) -> None: 

193 paths = fjc_biographies.run(tmp_path / 'out', jcs = True, martin_quinn = martin_quinn) 

194 assert paths['martin_quinn_terms'] == tmp_path / 'out' / 'martin_quinn_terms.csv' 

195 assert len(pd.read_csv(paths['martin_quinn_terms'])) == 4 

196 roster = pd.read_csv(paths['roster_by_appointment']) 

197 assert {'jcs', *MQ_COLUMNS} <= set(roster.columns) 

198 judges = pd.read_csv(paths['roster_by_judge']) 

199 assert {'jcs_1', 'mq_mean_1', 'jcs_2'} <= set(judges.columns) 

200 # The scores are optional. 

201 plain = fjc_biographies.run(tmp_path / 'plain') 

202 assert 'martin_quinn_terms' not in plain 

203 assert 'jcs' not in pd.read_csv(plain['roster_by_appointment']).columns 

204 

205 

206def test_cli_with_scores( 

207 requests: list[str], 

208 computed: dict[str, Any], 

209 martin_quinn: pathlib.Path, 

210 tmp_path: pathlib.Path) -> None: 

211 cli.main([ 

212 '--data-folder', str(tmp_path / 'out'), '--jcs', '--martin-quinn', 

213 str(martin_quinn), '--scores-folder', str(tmp_path / 'scores')]) 

214 roster = pd.read_csv(tmp_path / 'out' / 'roster_by_appointment.csv') 

215 assert roster.loc[roster['nid'] == 112, 'mq_terms'].iloc[0] == 3 

216 assert roster['jcs'].notna().any() 

217 assert computed['martin_quinn'] == martin_quinn 

218 

219 

220def calls_files(computed: dict[str, Any]) -> pathlib.Path: 

221 """Returns the folder of the FJC's files that the scores were computed from.""" 

222 files = computed['files'] 

223 folders = {files[kind].parent for kind in ('service', 'demographics', 'career')} 

224 assert len(folders) == 1 

225 return folders.pop()