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
« 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."""
3from __future__ import annotations
5import logging
6import pathlib
7from typing import Any
9import judicial_common_space as jcs
10import pandas as pd
11import pytest
12from conftest import SERVICE, tables
14import fjc_biographies
15from fjc_biographies import __main__ as cli
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')
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
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'])
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': []}
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 {}
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()
67 monkeypatch.setattr(jcs.sources, 'download', download)
68 monkeypatch.setattr(jcs, 'compute', compute)
69 return calls
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)
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
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'])
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
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)])
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'])
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]
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)
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
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
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()