import json import tempfile import unittest from datetime import UTC, datetime from pathlib import Path from app.services.phase3_kpi_export import ( KPI_REPORT_PATH, PHASE3_KPI_METRICS, PREPOST_CSV_PATH, ParticipantKeys, build_kpi_report, build_prepost_csv_rows, paired_prepost_summary, write_kpi_report, write_prepost_csv, ) from app.services.phase3_kpi_contract import KPI_METRIC_REQUIRED_KEYS def fixture_rows(): return [ { "learner_id": "11111111-1111-1111-1111-111111111111", "measure_name": "self_efficacy", "timepoint": "pre", "raw_score": 2, "min_score": 1, "max_score": 5, "collected_at": datetime(2026, 6, 27, 0, 0, tzinfo=UTC), "updated_at": datetime(2026, 6, 27, 0, 1, tzinfo=UTC), }, { "learner_id": "11111111-1111-1111-1111-111111111111", "measure_name": "self_efficacy", "timepoint": "pre", "raw_score": 3, "min_score": 1, "max_score": 5, "collected_at": datetime(2026, 6, 27, 0, 2, tzinfo=UTC), "updated_at": datetime(2026, 6, 27, 0, 3, tzinfo=UTC), }, { "learner_id": "11111111-1111-1111-1111-111111111111", "measure_name": "self_efficacy", "timepoint": "post", "raw_score": 4, "min_score": 1, "max_score": 5, "collected_at": datetime(2026, 6, 27, 1, 0, tzinfo=UTC), "updated_at": datetime(2026, 6, 27, 1, 0, tzinfo=UTC), }, { "learner_id": "22222222-2222-2222-2222-222222222222", "measure_name": "self_efficacy", "timepoint": "pre", "raw_score": 4, "min_score": 1, "max_score": 5, "collected_at": datetime(2026, 6, 27, 0, 5, tzinfo=UTC), "updated_at": datetime(2026, 6, 27, 0, 5, tzinfo=UTC), }, { "learner_id": "11111111-1111-1111-1111-111111111111", "measure_name": "skill_proficiency", "timepoint": "pre", "raw_score": 2, "min_score": 1, "max_score": 5, "collected_at": datetime(2026, 6, 27, 0, 10, tzinfo=UTC), "updated_at": datetime(2026, 6, 27, 0, 10, tzinfo=UTC), }, { "learner_id": "11111111-1111-1111-1111-111111111111", "measure_name": "skill_proficiency", "timepoint": "post", "raw_score": 5, "min_score": 1, "max_score": 5, "collected_at": datetime(2026, 6, 27, 1, 10, tzinfo=UTC), "updated_at": datetime(2026, 6, 27, 1, 10, tzinfo=UTC), }, ] class Phase3KpiExportTests(unittest.TestCase): def test_prepost_csv_rows_use_pseudonymous_participant_ids_and_latest_score(self) -> None: keys = ParticipantKeys() rows = build_prepost_csv_rows(fixture_rows(), participant_keys=keys) self.assertEqual(len(keys), 2) self.assertTrue(all(row["participant_id"].startswith("P3-") for row in rows)) blob = json.dumps(rows, ensure_ascii=False) self.assertNotIn("11111111-1111-1111-1111-111111111111", blob) self.assertNotIn("22222222-2222-2222-2222-222222222222", blob) p1_pre = [ row for row in rows if row["participant_id"] == "P3-001" and row["measure_name"] == "self_efficacy" and row["timepoint"] == "pre" ] self.assertEqual([{"participant_id": "P3-001", "measure_name": "self_efficacy", "timepoint": "pre", "score": "3", "collected_at": "2026-06-27T00:02:00Z"}], p1_pre) def test_paired_prepost_summary_uses_normalized_scores(self) -> None: summary = paired_prepost_summary(fixture_rows(), "self_efficacy") self.assertEqual(summary["participants_with_any_measure"], 2) self.assertEqual(summary["complete_pairs"], 1) self.assertEqual(summary["missing_pairs"], 1) self.assertEqual(summary["mean_pre"], 50.0) self.assertEqual(summary["mean_post"], 75.0) self.assertEqual(summary["mean_delta"], 25.0) def test_kpi_report_contains_checker_required_metric_shape(self) -> None: report = build_kpi_report( fixture_rows(), pilot_id="phase3-pilot-draft", generated_at="2026-06-28T00:00:00Z", review_operator="operator", ) self.assertEqual(report["pilot_id"], "phase3-pilot-draft") self.assertEqual(report["cohort_size"], 2) self.assertTrue(set(PHASE3_KPI_METRICS).issubset(report["metrics"])) for metric in report["metrics"].values(): self.assertTrue(KPI_METRIC_REQUIRED_KEYS.issubset(metric)) self_efficacy = report["metrics"]["self_efficacy_prepost"] self.assertFalse(self_efficacy["pass"]) self.assertEqual(self_efficacy["status"], "computed_prepost") self.assertEqual(self_efficacy["value"], 25.0) self.assertEqual(self_efficacy["source_files"], [PREPOST_CSV_PATH]) self.assertEqual(report["metrics"]["icc"]["status"], "design_pending") def test_writers_create_phase3_evidence_files(self) -> None: rows = build_prepost_csv_rows(fixture_rows(), participant_keys=ParticipantKeys()) report = build_kpi_report( fixture_rows(), pilot_id="phase3-pilot-draft", generated_at="2026-06-28T00:00:00Z", ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_prepost_csv(rows, root / PREPOST_CSV_PATH) write_kpi_report(report, root / KPI_REPORT_PATH) self.assertTrue((root / PREPOST_CSV_PATH).exists()) self.assertTrue((root / KPI_REPORT_PATH).exists()) csv_text = (root / PREPOST_CSV_PATH).read_text(encoding="utf-8") self.assertTrue(csv_text.startswith("participant_id,measure_name,timepoint,score,collected_at")) saved = json.loads((root / KPI_REPORT_PATH).read_text(encoding="utf-8")) self.assertEqual(saved["metrics"]["self_efficacy_prepost"]["complete_pairs"], 1) if __name__ == "__main__": unittest.main()