vignette/apps/api/app/test_phase3_kpi_export.py
2026-06-28 23:53:17 +09:00

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()