forked from ChelseaKR/constituent-reconciler
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_config.py
More file actions
300 lines (225 loc) · 11.5 KB
/
Copy pathtest_config.py
File metadata and controls
300 lines (225 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
"""Tests for recipe loading: the consent lifecycle columns, the [review]
two-person settings, and FIX-04's fail-closed shape validation.
FIX-06 extends the recipe's [consent] section with optional ``date``,
``expires``, and ``scope`` columns on top of the existing ``column`` and
``require`` keys. The consent tests cover only that surface; the broader
recipe loader is exercised indirectly by every other test that calls
``load_recipe``.
FIX-04 makes an unknown section or an unknown key inside a known section raise
``RecipeError`` (a ``ValueError`` subclass) instead of being silently ignored,
because the recipe is the one non-technical operators hand-edit and every
other config surface already raises on a typo.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from constituent_reconciler.config import RecipeError, load_recipe
from constituent_reconciler.policy import PolicyViolation
EXAMPLES = Path(__file__).resolve().parents[1] / "examples" / "intake-demo"
MINIMAL_INPUT = (
'[input]\nincoming = "incoming.csv"\n\n[mapping]\nfirst_name = "First"\nlast_name = "Last"\n'
)
def _write(tmp_path: Path, body: str) -> Path:
path = tmp_path / "recipe.toml"
path.write_text(body, encoding="utf-8")
(tmp_path / "incoming.csv").write_text("First,Last\n", encoding="utf-8")
return path
def _write_recipe(tmp_path: Path, extra: str) -> Path:
body = (
"[input]\n"
f'incoming = "{EXAMPLES / "incoming.csv"}"\n'
'id_column = "id"\n\n'
"[mapping]\n"
'first_name = "First Name"\n'
'last_name = "Last Name"\n\n'
f"{extra}"
)
path = tmp_path / "recipe.toml"
path.write_text(body, encoding="utf-8")
return path
def test_consent_column_only_leaves_date_expires_scope_unset(tmp_path: Path) -> None:
recipe = load_recipe(_write(tmp_path, MINIMAL_INPUT + '\n[consent]\ncolumn = "Consent"\n'))
assert recipe.consent_column == "Consent"
assert recipe.consent_date_column is None
assert recipe.consent_expires_column is None
assert recipe.consent_scope_column is None
def test_consent_section_maps_date_expires_and_scope_columns(tmp_path: Path) -> None:
recipe = load_recipe(
_write(
tmp_path,
MINIMAL_INPUT + "\n[consent]\n"
'column = "Consent"\n'
'date = "Consent Date"\n'
'expires = "Consent Expires"\n'
'scope = "Consent Scope"\n',
)
)
assert recipe.consent_column == "Consent"
assert recipe.consent_date_column == "Consent Date"
assert recipe.consent_expires_column == "Consent Expires"
assert recipe.consent_scope_column == "Consent Scope"
def test_no_consent_section_leaves_every_consent_field_unset(tmp_path: Path) -> None:
recipe = load_recipe(_write(tmp_path, MINIMAL_INPUT))
assert recipe.consent_column is None
assert recipe.consent_date_column is None
assert recipe.consent_expires_column is None
assert recipe.consent_scope_column is None
assert recipe.require_consent is False
def test_no_comparable_section_defaults_to_off_and_empty(tmp_path: Path) -> None:
recipe = load_recipe(_write(tmp_path, MINIMAL_INPUT))
assert recipe.comparable_export is False
assert recipe.comparable_breakdown_fields == ()
assert recipe.comparable_period == ""
def test_comparable_section_sets_export_breakdown_fields_and_period(tmp_path: Path) -> None:
recipe = load_recipe(
_write(
tmp_path,
MINIMAL_INPUT + "\n[comparable]\n"
"export = true\n"
'breakdown_fields = ["county", "program"]\n'
'period = "2026-Q2"\n',
)
)
assert recipe.comparable_export is True
assert recipe.comparable_breakdown_fields == ("county", "program")
assert recipe.comparable_period == "2026-Q2"
def test_comparable_breakdown_field_defaults_to_off_even_with_period_set(
tmp_path: Path,
) -> None:
# ``period``/``breakdown_fields`` may be set without ``export = true``: they
# still apply to the standalone ``export-comparable`` command, which does
# not gate on ``comparable_export`` (only ``run``/``apply`` do).
recipe = load_recipe(_write(tmp_path, MINIMAL_INPUT + '\n[comparable]\nperiod = "2026-Q2"\n'))
assert recipe.comparable_export is False
assert recipe.comparable_period == "2026-Q2"
def test_comparable_section_with_identifying_breakdown_field_rejected_at_load(
tmp_path: Path,
) -> None:
# Per the README's fail-closed claim, an identifying canonical field named
# as a comparable breakdown is refused when the recipe is loaded, before
# any record is read -- not only later, when the report is actually built.
with pytest.raises(PolicyViolation, match="identifying"):
load_recipe(
_write(
tmp_path,
MINIMAL_INPUT + '\n[comparable]\nbreakdown_fields = ["last_name"]\n',
)
)
# -- E4: two-person review settings -------------------------------------------
def test_review_section_defaults_off(tmp_path: Path) -> None:
recipe = load_recipe(_write_recipe(tmp_path, ""))
assert recipe.require_second_reviewer is False
def test_review_section_turns_two_person_review_on(tmp_path: Path) -> None:
recipe = load_recipe(_write_recipe(tmp_path, "[review]\nrequire_second_reviewer = true\n"))
assert recipe.require_second_reviewer is True
def test_recipe_cannot_turn_off_the_dv_packs_requirement(tmp_path: Path) -> None:
path = _write_recipe(
tmp_path,
'[policy]\npack = "dv"\n\n[review]\nrequire_second_reviewer = false\n',
)
recipe = load_recipe(path)
assert recipe.require_second_reviewer is True
def test_dv_pack_defaults_two_person_review_on() -> None:
recipe = load_recipe(EXAMPLES / "recipe-dv.toml")
assert recipe.require_second_reviewer is True
def test_default_pack_leaves_two_person_review_off() -> None:
recipe = load_recipe(EXAMPLES / "recipe.toml")
assert recipe.require_second_reviewer is False
# -- FIX-04: fail-closed shape validation ------------------------------------
def test_unknown_section_is_rejected_with_a_suggestion(tmp_path: Path) -> None:
# The exact typo the ideation pitch names: a misspelled [consent] section.
path = _write(tmp_path, MINIMAL_INPUT + '\n[consnet]\ncolumn = "Consent"\n')
with pytest.raises(RecipeError, match=r"unknown section \[consnet\].*consent"):
load_recipe(path)
def test_unknown_key_in_a_known_section_is_rejected(tmp_path: Path) -> None:
# The exact typo the ideation pitch names: auto_threshold instead of auto.
path = _write(tmp_path, MINIMAL_INPUT + "\n[thresholds]\nauto_threshold = 0.99\n")
with pytest.raises(RecipeError, match=r"unknown key 'auto_threshold'"):
load_recipe(path)
def test_unknown_key_names_the_nearest_valid_spelling(tmp_path: Path) -> None:
# Close enough for difflib to suggest the real key, unlike "auto_threshold".
path = _write(tmp_path, MINIMAL_INPUT + "\n[thresholds]\natuo = 0.99\n")
with pytest.raises(RecipeError, match=r"unknown key 'atuo'.*did you mean 'auto'"):
load_recipe(path)
def test_unknown_mapping_key_is_rejected_rather_than_silently_dropped(tmp_path: Path) -> None:
# Before FIX-04, a mapping key outside CANONICAL_FIELDS was filtered out
# silently by a dict comprehension; a typo'd canonical field name (here,
# "frist_name") used to vanish instead of raising.
path = _write(
tmp_path,
'[input]\nincoming = "incoming.csv"\n\n[mapping]\n'
'frist_name = "First"\nlast_name = "Last"\n',
)
with pytest.raises(RecipeError, match=r"unknown canonical field 'frist_name'"):
load_recipe(path)
def test_a_section_body_that_is_not_a_table_is_rejected(tmp_path: Path) -> None:
# [[thresholds]] (array-of-tables syntax) makes "thresholds" a list, not a
# table -- a genuinely malformed shape rather than an unknown key.
path = _write(tmp_path, MINIMAL_INPUT + "\n[[thresholds]]\nauto = 0.9\n")
with pytest.raises(RecipeError, match="must be a table"):
load_recipe(path)
def test_every_committed_example_recipe_validates(tmp_path: Path) -> None:
# Every shipped example must pass the strict validator unmodified; a
# regression here would mean the schema drifted from what the recipes use.
examples_root = Path(__file__).resolve().parents[1] / "examples"
recipe_paths = sorted(examples_root.rglob("recipe*.toml"))
assert recipe_paths, "expected at least one example recipe"
for recipe_path in recipe_paths:
load_recipe(recipe_path) # raises RecipeError on any drift
def test_recipe_error_is_a_value_error(tmp_path: Path) -> None:
# Callers that already catch ValueError around load_recipe keep working.
path = _write(tmp_path, "")
with pytest.raises(ValueError):
load_recipe(path)
# -- FIX-04: `reconcile validate` --------------------------------------------
def test_validate_command_accepts_a_good_recipe(capsys: object) -> None:
from constituent_reconciler.cli import main
examples = Path(__file__).resolve().parents[1] / "examples" / "intake-demo"
code = main(["validate", "--config", str(examples / "recipe.toml")])
out = capsys.readouterr().out # type: ignore[attr-defined]
assert code == 0
assert "recipe is valid." in out
assert "mapped fields: first_name, last_name, dob, email, phone" in out
assert "policy pack: default" in out
def test_validate_command_reports_an_invalid_recipe(tmp_path: Path, capsys: object) -> None:
from constituent_reconciler.cli import main
path = _write(tmp_path, MINIMAL_INPUT + "\n[thresholds]\nauto_threshold = 0.99\n")
code = main(["validate", "--config", str(path)])
err = capsys.readouterr().err # type: ignore[attr-defined]
assert code == 2
assert "invalid recipe" in err
assert "auto_threshold" in err
def test_validate_command_reports_a_missing_incoming_file(tmp_path: Path, capsys: object) -> None:
from constituent_reconciler.cli import main
path = tmp_path / "recipe.toml"
path.write_text(MINIMAL_INPUT, encoding="utf-8")
# Deliberately do not write incoming.csv, unlike _write().
code = main(["validate", "--config", str(path)])
err = capsys.readouterr().err # type: ignore[attr-defined]
assert code == 2
assert "does not exist" in err
def test_review_calibration_must_be_a_non_negative_integer(tmp_path: Path) -> None:
recipe = load_recipe(_write(tmp_path, MINIMAL_INPUT + "\n[review]\ncalibration = 3\n"))
assert recipe.review_calibration == 3
for invalid in ("-1", "1.5", "true"):
path = _write(tmp_path, MINIMAL_INPUT + f"\n[review]\ncalibration = {invalid}\n")
with pytest.raises(RecipeError, match="calibration"):
load_recipe(path)
# ---------------------------------------------------------------------------
# [extract] sandbox and the recorded recipe path (FIX-10 / FIX-08 wiring)
# ---------------------------------------------------------------------------
def test_extract_sandbox_defaults_on(tmp_path: Path) -> None:
recipe = load_recipe(_write(tmp_path, MINIMAL_INPUT + '\n[extract]\nbackend = "pdfplumber"\n'))
assert recipe.extract.sandbox is True
def test_extract_sandbox_opt_out_is_parsed(tmp_path: Path) -> None:
recipe = load_recipe(
_write(
tmp_path,
MINIMAL_INPUT + '\n[extract]\nbackend = "pdfplumber"\nsandbox = false\n',
)
)
assert recipe.extract.sandbox is False
def test_load_recipe_records_the_recipe_path(tmp_path: Path) -> None:
path = _write(tmp_path, MINIMAL_INPUT)
recipe = load_recipe(path)
assert recipe.recipe_path == path