diff --git a/backend/app/scoring/timing.py b/backend/app/scoring/timing.py index 6e78490..31b2a8a 100644 --- a/backend/app/scoring/timing.py +++ b/backend/app/scoring/timing.py @@ -5,9 +5,94 @@ нулевое время даёт полный вклад, два норматива и более — нулевой. """ +from dataclasses import dataclass + +from app.domain.events import Metric +from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource +from app.scoring.taxonomy import METRIC_WEIGHTS + +TIME_REF = "ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026" + def time_credit(elapsed_ms: int | None, limit_ms: int) -> float: """Доля веса временной метрики, 0–1; отсутствие доказанного времени — 0.""" if elapsed_ms is None or elapsed_ms < 0 or limit_ms <= 0: return 0.0 return round(max(0.0, min(1.0, 1.0 - elapsed_ms / (2 * limit_ms))), 4) + + +@dataclass(frozen=True) +class TimeNorm: + """Формулировки норматива времени: карточка 112 и карточка ДДС различаются + только словами, расчёт и отметка E3 у них общие.""" + + key: str + title: str + summary: str + action: str + unmeasured: str + incomplete: str + + +CARD_FILL_TIME = TimeNorm( + key="card_fill_time", + title="Время заполнения карточки", + summary="Время заполнения карточки: норматив не выполнен", + action="сдать карточку", + unmeasured="время не зафиксировано", + incomplete="карточка не сдана", +) +DDS_WORK_TIME = TimeNorm( + key="dds_work_time", + title="Отработка карточки ДДС", + summary="ДДС: норматив времени отработки карточки не выполнен", + action="завершить", + unmeasured="время обработки не зафиксировано", + incomplete="карточка не завершена", +) + + +def time_metric( + norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool +) -> tuple[Metric, Finding | None]: + """Метрика норматива и отметка E3, если он не выполнен. + + Незавершённая работа не получает временного вклада: быстрое, но брошенное + заполнение не должно выглядеть скоростью. + """ + if elapsed_ms is None: + fact = norm.unmeasured + else: + delta_ms = elapsed_ms - limit_ms + if delta_ms > 0: + deviation = f"+{round(delta_ms / 1000)} с сверх норматива" + elif delta_ms < 0: + deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива" + else: + deviation = "точно в норматив" + fact = f"{round(elapsed_ms / 1000)} с ({deviation})" + if not completed: + fact = f"{norm.incomplete}; {fact}" + norm_text = f"{norm.action} за {round(limit_ms / 1000)} с" + passed = completed and elapsed_ms is not None and elapsed_ms <= limit_ms + metric = Metric( + key=norm.key, + title=norm.title, + fact=fact, + norm=norm_text, + ref=TIME_REF, + passed=passed, + weight=METRIC_WEIGHTS[norm.key], + credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0, + ) + if passed: + return metric, None + return metric, Finding( + code=ErrorCode.E3, + source=FindingSource.TIMERS, + summary=norm.summary, + fact=fact, + norm=norm_text, + ref=TIME_REF, + competency=Competency.NORMS, + ) diff --git a/backend/tests/test_timing_score.py b/backend/tests/test_timing_score.py index 19d3cd6..a5bf071 100644 --- a/backend/tests/test_timing_score.py +++ b/backend/tests/test_timing_score.py @@ -1,9 +1,10 @@ import pytest from app.domain.events import Metric +from app.domain.taxonomy import Competency, ErrorCode, FindingSource from app.scoring.competency import radar from app.scoring.gost import GostResult -from app.scoring.timing import time_credit +from app.scoring.timing import CARD_FILL_TIME, DDS_WORK_TIME, time_credit, time_metric @pytest.mark.parametrize( @@ -23,3 +24,57 @@ def test_fractional_timing_credit_affects_total_and_competency_scores(): result = GostResult(metrics=[metric]) assert result.score == 75.0 assert radar([metric])[0].value == 0.75 + + +@pytest.mark.parametrize( + ("elapsed_ms", "completed", "fact", "passed"), + [ + (200_000, True, "200 с (+20 с сверх норматива)", False), + (61_000, True, "61 с (на 119 с быстрее норматива)", True), + (180_000, True, "180 с (точно в норматив)", True), + (None, True, "время не зафиксировано", False), + (30_000, False, "карточка не сдана; 30 с (на 150 с быстрее норматива)", False), + ], +) +def test_card_fill_time_metric_texts(elapsed_ms, completed, fact, passed): + metric, finding = time_metric(CARD_FILL_TIME, elapsed_ms, 180_000, completed=completed) + + assert metric.key == "card_fill_time" + assert metric.title == "Время заполнения карточки" + assert metric.fact == fact + assert metric.norm == "сдать карточку за 180 с" + assert metric.ref == "ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026" + assert metric.passed is passed + assert metric.credit == (time_credit(elapsed_ms, 180_000) if completed else 0.0) + if passed: + assert finding is None + else: + assert finding.code is ErrorCode.E3 + assert finding.source is FindingSource.TIMERS + assert finding.competency is Competency.NORMS + assert finding.summary == "Время заполнения карточки: норматив не выполнен" + assert (finding.fact, finding.norm) == (fact, "сдать карточку за 180 с") + + +@pytest.mark.parametrize( + ("elapsed_ms", "completed", "fact", "passed"), + [ + (605_000, True, "605 с (+425 с сверх норматива)", False), + (25_000, True, "25 с (на 155 с быстрее норматива)", True), + (180_000, True, "180 с (точно в норматив)", True), + (None, False, "карточка не завершена; время обработки не зафиксировано", False), + (25_000, False, "карточка не завершена; 25 с (на 155 с быстрее норматива)", False), + ], +) +def test_dds_work_time_metric_texts(elapsed_ms, completed, fact, passed): + metric, finding = time_metric(DDS_WORK_TIME, elapsed_ms, 180_000, completed=completed) + + assert metric.key == "dds_work_time" + assert metric.title == "Отработка карточки ДДС" + assert metric.fact == fact + assert metric.norm == "завершить за 180 с" + assert metric.passed is passed + assert (finding is None) is passed + if finding is not None: + assert finding.summary == "ДДС: норматив времени отработки карточки не выполнен" + assert (finding.fact, finding.norm) == (fact, "завершить за 180 с")