166 lines
6.2 KiB
Python
166 lines
6.2 KiB
Python
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,
|
|
)
|
|
|
|
|
|
REQUIRED_METRIC_KEYS = {
|
|
"denominator",
|
|
"method",
|
|
"numerator",
|
|
"pass",
|
|
"source_files",
|
|
"status",
|
|
"threshold",
|
|
"value",
|
|
}
|
|
|
|
|
|
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(REQUIRED_METRIC_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()
|