1
+"""Tests for momentum_tracker and calibrate_hype_risk."""
2
+
3
+import json
4
+import sys
5
+from pathlib import Path
6
+
7
+import pytest
8
+
9
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
10
+from momentum_tracker import ( # noqa: E402
11
+ classify_momentum,
12
+ compute_decay_rate,
13
+ current_iso_week,
14
+ extract_correlated_repos,
15
+ find_correlation_file,
16
+ get_repo_stars_gained,
17
+ iso_week_to_date,
18
+ load_json_safe,
19
+ run_momentum_tracking,
20
+ track_repo_momentum,
21
+ update_predictions_validated,
22
+ week_offset,
23
+)
24
+from calibrate_hype_risk import ( # noqa: E402
25
+ build_actual_outcomes,
26
+ build_predictions,
27
+ compute_calibration,
28
+ generate_recommendations,
29
+ risk_to_expected_outcome,
30
+ run_calibration,
31
+)
32
+
33
+
34
+# ---------------------------------------------------------------------------
35
+# momentum_tracker tests
36
+# ---------------------------------------------------------------------------
37
+
38
+
39
+class TestWeekUtils:
40
+ """Test ISO week utility functions."""
41
+
42
+ def test_current_iso_week_format(self):
43
+ """current_iso_week returns YYYY-WNN format."""
44
+ week = current_iso_week()
45
+ assert len(week) >= 7
46
+ assert "-W" in week
47
+
48
+ def test_week_offset_forward(self):
49
+ """week_offset moves forward correctly."""
50
+ assert week_offset("2026-W21", 2) == "2026-W23"
51
+
52
+ def test_week_offset_backward(self):
53
+ """week_offset moves backward correctly."""
54
+ assert week_offset("2026-W03", -2) == "2026-W01"
55
+
56
+ def test_week_offset_year_boundary(self):
57
+ """week_offset crosses year boundary."""
58
+ result = week_offset("2025-W52", 2)
59
+ assert result.startswith("2026-W")
60
+
61
+ def test_iso_week_to_date(self):
62
+ """iso_week_to_date returns correct Monday."""
63
+ dt = iso_week_to_date("2026-W01")
64
+ assert dt.weekday() == 0 # Monday
65
+
66
+
67
+class TestClassifyMomentum:
68
+ """Test momentum classification logic."""
69
+
70
+ def test_sustained_strong_growth(self):
71
+ """Still gaining 20%+ of initial → sustained."""
72
+ assert classify_momentum(100, 50, 30, lag=4) == "sustained"
73
+
74
+ def test_faded_no_growth(self):
75
+ """Zero growth at checkpoints → faded."""
76
+ assert classify_momentum(100, 5, 3, lag=4) == "faded"
77
+
78
+ def test_faded_zero_initial(self):
79
+ """Zero initial gained → faded."""
80
+ assert classify_momentum(0, 10, 5, lag=4) == "faded"
81
+
82
+ def test_sustained_week2_only(self):
83
+ """With lag=2, only week2 data used."""
84
+ assert classify_momentum(100, 30, None, lag=2) == "sustained"
85
+
86
+ def test_faded_no_data(self):
87
+ """No follow-up data → faded (conservative)."""
88
+ assert classify_momentum(100, None, None, lag=4) == "faded"
89
+
90
+ def test_sustained_at_threshold(self):
91
+ """Exactly 20% of initial → sustained."""
92
+ assert classify_momentum(100, 20, 20, lag=4) == "sustained"
93
+
94
+ def test_faded_below_threshold(self):
95
+ """Just below 20% → faded."""
96
+ assert classify_momentum(100, 19, 19, lag=4) == "faded"
97
+
98
+
99
+class TestDecayRate:
100
+ """Test decay rate computation."""
101
+
102
+ def test_no_decay(self):
103
+ assert compute_decay_rate(100, 100) == 0.0
104
+
105
+ def test_full_decay(self):
106
+ assert compute_decay_rate(100, 0) == 1.0
107
+
108
+ def test_partial_decay(self):
109
+ assert compute_decay_rate(100, 50) == 0.5
110
+
111
+ def test_zero_initial(self):
112
+ assert compute_decay_rate(0, 50) == 0.0
113
+
114
+ def test_negative_clamped(self):
115
+ assert compute_decay_rate(100, 150) == 0.0
116
+
117
+
118
+class TestExtractCorrelatedRepos:
119
+ """Test extraction from correlation data."""
120
+
121
+ def test_extracts_correlated(self):
122
+ data = {
123
+ "correlations": [
124
+ {"repo": "org/a", "press_correlated": True},
125
+ {"repo": "org/b", "press_correlated": False},
126
+ {"repo": "org/c", "press_correlated": True},
127
+ ]
128
+ }
129
+ result = extract_correlated_repos(data)
130
+ assert len(result) == 2
131
+ assert result[0]["repo"] == "org/a"
132
+ assert result[1]["repo"] == "org/c"
133
+
134
+ def test_empty_correlations(self):
135
+ assert extract_correlated_repos({"correlations": []}) == []
136
+
137
+
138
+class TestGetRepoStarsGained:
139
+ """Test star extraction from raw data."""
140
+
141
+ def test_from_repos_key(self):
142
+ data = {"repos": [{"full_name": "org/x", "stars_gained": 42}]}
143
+ assert get_repo_stars_gained(data, "org/x") == 42
144
+
145
+ def test_missing_repo(self):
146
+ data = {"repos": [{"full_name": "org/x", "stars_gained": 42}]}
147
+ assert get_repo_stars_gained(data, "org/y") is None
148
+
149
+ def test_none_data(self):
150
+ assert get_repo_stars_gained(None, "org/x") is None
151
+
152
+
153
+class TestTrackRepoMomentum:
154
+ """Test single repo momentum tracking."""
155
+
156
+ def test_with_data(self, tmp_path):
157
+ w2_data = {"repos": [{"full_name": "org/x", "stars_gained": 50}]}
158
+ (tmp_path / "2026-W23.json").write_text(json.dumps(w2_data))
159
+ w4_data = {"repos": [{"full_name": "org/x", "stars_gained": 30}]}
160
+ (tmp_path / "2026-W25.json").write_text(json.dumps(w4_data))
161
+
162
+ result = track_repo_momentum("org/x", 200, tmp_path, "2026-W21", lag=4)
163
+ assert result["repo"] == "org/x"
164
+ assert result["initial_stars_gained"] == 200
165
+ assert result["week2_stars_gained"] == 50
166
+ assert result["week4_stars_gained"] == 30
167
+ assert result["classification"] == "faded"
168
+ assert result["decay_rate"] > 0
169
+
170
+ def test_missing_weeks(self, tmp_path):
171
+ result = track_repo_momentum("org/x", 100, tmp_path, "2026-W21", lag=4)
172
+ assert result["week2_stars_gained"] is None
173
+ assert result["week4_stars_gained"] is None
174
+ assert result["classification"] == "faded"
175
+
176
+
177
+class TestUpdatePredictions:
178
+ """Test predictions.jsonl update."""
179
+
180
+ def test_updates_matching(self, tmp_path):
181
+ preds = [
182
+ {"repo": "org/a", "prediction": "rising_star", "week": "2026-W20"},
183
+ {"repo": "org/b", "prediction": "rising_star", "week": "2026-W20"},
184
+ ]
185
+ pred_path = tmp_path / "predictions.jsonl"
186
+ pred_path.write_text("\n".join(json.dumps(p) for p in preds) + "\n")
187
+
188
+ tracked = [
189
+ {"repo": "org/a", "classification": "sustained"},
190
+ {"repo": "org/b", "classification": "faded"},
191
+ ]
192
+ updated = update_predictions_validated(pred_path, tracked)
193
+ assert updated == 2
194
+
195
+ lines = pred_path.read_text().strip().split("\n")
196
+ result = [json.loads(line) for line in lines]
197
+ assert result[0]["validated"] is True
198
+ assert result[1]["validated"] is False
199
+
200
+ def test_skips_already_validated(self, tmp_path):
201
+ preds = [{"repo": "org/a", "validated": True, "week": "2026-W20"}]
202
+ pred_path = tmp_path / "predictions.jsonl"
203
+ pred_path.write_text(json.dumps(preds[0]) + "\n")
204
+
205
+ tracked = [{"repo": "org/a", "classification": "faded"}]
206
+ updated = update_predictions_validated(pred_path, tracked)
207
+ assert updated == 0
208
+
209
+ def test_missing_file(self, tmp_path):
210
+ updated = update_predictions_validated(tmp_path / "nope.jsonl", [])
211
+ assert updated == 0
212
+
213
+
214
+class TestRunMomentumTracking:
215
+ """Integration test for full tracking run."""
216
+
217
+ def test_no_correlations(self, tmp_path, monkeypatch):
218
+ import scripts.topic_paths as tp
219
+
220
+ monkeypatch.setattr(tp, "DATA_ROOT", tmp_path / "data")
221
+ (tmp_path / "data" / "raw").mkdir(parents=True)
222
+ (tmp_path / "data" / "analyzed").mkdir(parents=True)
223
+ (tmp_path / "data" / "metrics").mkdir(parents=True)
224
+
225
+ result = run_momentum_tracking(topic_id=None, week="2026-W21", lag=4)
226
+ assert result["week"] == "2026-W21"
227
+ assert result["tracked_repos"] == []
228
+ assert result["summary"]["total"] == 0
229
+
230
+
231
+# ---------------------------------------------------------------------------
232
+# calibrate_hype_risk tests
233
+# ---------------------------------------------------------------------------
234
+
235
+
236
+class TestBuildActualOutcomes:
237
+ """Test outcome extraction from momentum data."""
238
+
239
+ def test_extracts_outcomes(self):
240
+ data = [
241
+ {
242
+ "tracked_repos": [
243
+ {"repo": "org/a", "classification": "sustained"},
244
+ {"repo": "org/b", "classification": "faded"},
245
+ ]
246
+ }
247
+ ]
248
+ outcomes = build_actual_outcomes(data)
249
+ assert outcomes["org/a"] == "sustained"
250
+ assert outcomes["org/b"] == "faded"
251
+
252
+ def test_latest_wins(self):
253
+ data = [
254
+ {"tracked_repos": [{"repo": "org/a", "classification": "faded"}]},
255
+ {"tracked_repos": [{"repo": "org/a", "classification": "sustained"}]},
256
+ ]
257
+ outcomes = build_actual_outcomes(data)
258
+ assert outcomes["org/a"] == "sustained"
259
+
260
+
261
+class TestBuildPredictions:
262
+ """Test prediction extraction from hype risk data."""
263
+
264
+ def test_extracts_risks(self):
265
+ data = [
266
+ {
267
+ "assessments": [
268
+ {"repo": "org/a", "hype_risk": "high"},
269
+ {"repo": "org/b", "hype_risk": "low"},
270
+ ]
271
+ }
272
+ ]
273
+ preds = build_predictions(data)
274
+ assert preds["org/a"] == "high"
275
+ assert preds["org/b"] == "low"
276
+
277
+
278
+class TestRiskToExpectedOutcome:
279
+ """Test risk level to outcome mapping."""
280
+
281
+ def test_high_expects_faded(self):
282
+ assert risk_to_expected_outcome("high") == "faded"
283
+
284
+ def test_low_expects_sustained(self):
285
+ assert risk_to_expected_outcome("low") == "sustained"
286
+
287
+ def test_very_low_expects_sustained(self):
288
+ assert risk_to_expected_outcome("very_low") == "sustained"
289
+
290
+ def test_medium_uncertain(self):
291
+ assert risk_to_expected_outcome("medium") is None
292
+
293
+ def test_none_uncertain(self):
294
+ assert risk_to_expected_outcome("none") is None
295
+
296
+
297
+class TestComputeCalibration:
298
+ """Test calibration computation."""
299
+
300
+ def test_perfect_accuracy(self):
301
+ predictions = {"org/a": "high", "org/b": "low"}
302
+ actuals = {"org/a": "faded", "org/b": "sustained"}
303
+ result = compute_calibration(predictions, actuals)
304
+ assert result["samples"] == 2
305
+ assert result["accuracy_by_category"]["high"]["accuracy"] == 1.0
306
+ assert result["accuracy_by_category"]["low"]["accuracy"] == 1.0
307
+
308
+ def test_partial_accuracy(self):
309
+ predictions = {"org/a": "high", "org/b": "high"}
310
+ actuals = {"org/a": "faded", "org/b": "sustained"}
311
+ result = compute_calibration(predictions, actuals)
312
+ assert result["samples"] == 2
313
+ assert result["accuracy_by_category"]["high"]["correct"] == 1
314
+ assert result["accuracy_by_category"]["high"]["predicted"] == 2
315
+
316
+ def test_no_overlap(self):
317
+ predictions = {"org/a": "high"}
318
+ actuals = {"org/z": "faded"}
319
+ result = compute_calibration(predictions, actuals)
320
+ assert result["samples"] == 0
321
+
322
+
323
+class TestGenerateRecommendations:
324
+ """Test recommendation generation."""
325
+
326
+ def test_low_high_accuracy_triggers_adjustment(self):
327
+ calibration = {
328
+ "accuracy_by_category": {
329
+ "high": {"predicted": 10, "correct": 5, "accuracy": 0.5}
330
+ }
331
+ }
332
+ actuals = {"org/a": "sustained", "org/b": "faded"}
333
+ recs = generate_recommendations(calibration, actuals)
334
+ params = [r["parameter"] for r in recs]
335
+ assert "high_risk_decay_threshold" in params
336
+
337
+ def test_good_accuracy_no_changes(self):
338
+ calibration = {
339
+ "accuracy_by_category": {
340
+ "high": {"predicted": 10, "correct": 9, "accuracy": 0.9},
341
+ "low": {"predicted": 10, "correct": 9, "accuracy": 0.9},
342
+ }
343
+ }
344
+ actuals = {"org/a": "sustained", "org/b": "faded"}
345
+ recs = generate_recommendations(calibration, actuals)
346
+ params = [r["parameter"] for r in recs]
347
+ assert "no_changes" in params
348
+
349
+
350
+class TestRunCalibration:
351
+ """Integration test for calibration."""
352
+
353
+ def test_no_data(self, tmp_path, monkeypatch):
354
+ import scripts.topic_paths as tp
355
+
356
+ monkeypatch.setattr(tp, "DATA_ROOT", tmp_path / "data")
357
+ (tmp_path / "data" / "metrics").mkdir(parents=True)
358
+ (tmp_path / "data" / "analyzed").mkdir(parents=True)
359
+
360
+ result = run_calibration(topic_id=None, output_path=str(tmp_path / "out.json"))
361
+ assert result["samples"] == 0
362
+ assert result["recommended_adjustments"] == []