refactor: оценка занятия по неизменяемому снимку, грамматика параметром, тесты без SimpleNamespace

This commit is contained in:
gglamer 2026-09-27 08:06:05 +00:00
commit 9f2ef7691f
5 changed files with 506 additions and 249 deletions

View file

@ -1,12 +1,28 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.domain.statuses import ServiceStatus, StatusEntry
from app.domain.taxonomy import ErrorCode
from app.scenarios.loader import load_file
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.session.dds import DdsLiveCard, build_card
from app.session.timers import SessionTimers
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
SERVICES = ["Служба 101"]
def live_card(*, status_log, crew_assignments, dispatched_at) -> DdsLiveCard:
"""Карточка пульта с заданным журналом статусов — вход оценки ДДС."""
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
card = build_card(scenario, 0, SessionTimers().limits)
card.status_log = status_log
card.crew_assignments = crew_assignments
card.dispatched_at = dispatched_at
return card
def test_late_primary_status_has_d1_finding_even_when_status_exists():
@ -110,8 +126,7 @@ def test_missing_manual_status_comment_has_a_d5_finding():
def test_each_manual_status_comment_is_part_of_the_numeric_reply_metric():
at = datetime.now(timezone.utc)
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"],
card = live_card(
status_log=[
StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
@ -127,7 +142,7 @@ def test_each_manual_status_comment_is_part_of_the_numeric_reply_metric():
)
reply = next(
metric for metric in dispatcher_metrics(state, 30_000)
metric for metric in dispatcher_metrics(card, 30_000, services=SERVICES)
if metric.key == "dds_reply"
)
@ -135,9 +150,9 @@ def test_each_manual_status_comment_is_part_of_the_numeric_reply_metric():
assert "к каждой ручной отметке" in reply.norm
findings = evaluate_dispatcher(
entries=state.status_log,
entries=card.status_log,
services=["Служба 101"],
crew_assignments=state.crew_assignments,
crew_assignments=card.crew_assignments,
deadline_ms=30_000,
elapsed_ms=0,
)
@ -173,13 +188,13 @@ def test_scenario_may_define_a_valid_decline_for_duplicate_or_territory():
expected_decision_reason="дублирующая карточка",
)
assert ErrorCode.D3 not in [finding.code for finding in findings]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
card = live_card(
status_log=entries,
crew_assignments={}, dispatched_at=at,
)
metrics = dispatcher_metrics(
state, 30_000, expected_decision="decline",
expected_decision_reason="дублирующая карточка",
card, 30_000, expected_decision="decline",
expected_decision_reason="дублирующая карточка", services=SERVICES,
)
decision = next(metric for metric in metrics if metric.key == "dds_decision")
assert decision.passed
@ -188,8 +203,7 @@ def test_scenario_may_define_a_valid_decline_for_duplicate_or_territory():
def test_nonempty_unstructured_comment_fails_reply_metric_but_structured_passes():
at = datetime.now(timezone.utc)
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"],
card = live_card(
status_log=[StatusEntry(
service="Служба 101", status=ServiceStatus.ACCEPTED,
at=at, comment="бригада на связи",
@ -197,19 +211,19 @@ def test_nonempty_unstructured_comment_fails_reply_metric_but_structured_passes(
crew_assignments={"Служба 101": "Бригада 12"},
dispatched_at=at,
)
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
reply = next(m for m in dispatcher_metrics(card, 30_000, services=SERVICES) if m.key == "dds_reply")
assert not reply.passed
findings = evaluate_dispatcher(
entries=state.status_log, services=["Служба 101"],
crew_assignments=state.crew_assignments,
entries=card.status_log, services=["Служба 101"],
crew_assignments=card.crew_assignments,
deadline_ms=30_000, elapsed_ms=0,
)
assert any("не разделяют основание и сведения" in f.summary for f in findings)
state.status_log[0] = state.status_log[0].model_copy(update={
card.status_log[0] = card.status_log[0].model_copy(update={
"comment": "Основание: доклад старшего.\nСведения: бригада на связи.",
})
reply = next(m for m in dispatcher_metrics(state, 30_000) if m.key == "dds_reply")
reply = next(m for m in dispatcher_metrics(card, 30_000, services=SERVICES) if m.key == "dds_reply")
assert reply.passed
def test_accepting_card_with_scenario_expected_decline_is_explained():
@ -242,11 +256,11 @@ def test_incomplete_work_path_has_d6_for_failed_progress_metrics():
StatusEntry(service="Служба 101", status=ServiceStatus.ACCEPTED, at=at),
StatusEntry(service="Служба 101", status=ServiceStatus.RESPONDING, at=at),
]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
card = live_card(
status_log=entries,
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
)
metrics = dispatcher_metrics(state, 30_000)
metrics = dispatcher_metrics(card, 30_000, services=SERVICES)
assert not next(item for item in metrics if item.key == "dds_progress").passed
assert not next(item for item in metrics if item.key == "dds_completion").passed
findings = evaluate_dispatcher(
@ -267,11 +281,11 @@ def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
comment="Бригаде переданы сведения, выезд не выполнялся по причине угрозы.",
),
]
state = SimpleNamespace(
managed_services=lambda: ["Служба 101"], status_log=entries,
card = live_card(
status_log=entries,
crew_assignments={"Служба 101": "Бригада 1"}, dispatched_at=at,
)
metrics = dispatcher_metrics(state, 30_000)
metrics = dispatcher_metrics(card, 30_000, services=SERVICES)
assert next(item for item in metrics if item.key == "dds_progress").passed
assert next(item for item in metrics if item.key == "dds_completion").passed
findings = evaluate_dispatcher(

View file

@ -0,0 +1,179 @@
"""Оценка занятия по снимку: без websocket, без живого `state` внутри расчёта."""
import asyncio
from dataclasses import FrozenInstanceError
from pathlib import Path
from uuid import uuid4
import pytest
from app.domain.events import Exercise, LessonCriteria, SessionMode
from app.domain.kio import KIO
from app.domain.statuses import ServiceStatus
from app.domain.taxonomy import ErrorCode, FindingSource
from app.scenarios.loader import load_file
from app.scoring.grammar import GrammarAssessment
from app.session import finish as finish_module
from app.session.dds import prepare_handoff_queue, prepare_queue
from app.session.hub import hub
from app.session.score import ScoringSnapshot, score_session
from app.session.state import SessionState
from app.session.store import MemorySessionStore
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
def scenario(name: str):
return load_file(LIBRARY / name, LIBRARY)
def card_state(*, grammar: bool = True, handoff: bool = False) -> SessionState:
operator = scenario("fire-apartment-l2.yaml")
state = SessionState(
session_id=uuid4(), scenario_id=operator.id, scenario_title=operator.title,
level=operator.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CARD,
handoff_to_dds=handoff, scenario=operator,
criteria=LessonCriteria(require_correct_grammar=grammar),
)
state.kio = KIO(
address="улица Ленина, 14", incident_type="fire", victims_count=2,
description="горит балкон", notify=["Служба 101"],
)
state.on_event("card.start")
state.dispatch()
state.on_event("card.submit")
if handoff:
prepare_handoff_queue(state, [scenario("tickets/t01-1-fire-container.yaml")])
return state
def dds_state() -> SessionState:
first = scenario("fire-apartment-l2.yaml")
state = SessionState(
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
criteria=LessonCriteria(require_correct_grammar=True),
)
prepare_queue(state, [first, scenario("tickets/t20-2-stroke.yaml")])
return state
def grammar_says(passed: bool, calls: list[str]):
async def check(text: str) -> GrammarAssessment:
calls.append(text)
return GrammarAssessment(
passed=passed, errors=() if passed else ("нет завершающего знака препинания",),
source="test",
)
return check
def score(snapshot, grammar):
return asyncio.run(score_session(snapshot, grammar=grammar))
@pytest.fixture(autouse=True)
def offline_finish(monkeypatch):
class NoCoaching:
def model_dump(self, **_kwargs):
return {}
async def no_coach(_metrics):
return NoCoaching()
monkeypatch.setattr(finish_module, "coach", no_coach)
monkeypatch.setattr(hub, "store", MemorySessionStore())
def test_passed_grammar_check_adds_passed_metric_without_e4():
calls: list[str] = []
snapshot = ScoringSnapshot.of(card_state())
report = score(snapshot, grammar_says(True, calls))
assert calls == ["горит балкон"]
metric = next(item for item in report.result.metrics if item.key == "description_grammar")
assert metric.passed and metric.fact == "ошибок не обнаружено"
assert ErrorCode.E4 not in [item.code for item in report.result.findings]
def test_failed_grammar_check_adds_e4_and_lowers_score():
passed = score(ScoringSnapshot.of(card_state()), grammar_says(True, []))
failed = score(ScoringSnapshot.of(card_state()), grammar_says(False, []))
metric = next(item for item in failed.result.metrics if item.key == "description_grammar")
assert not metric.passed
assert metric.fact == "нет завершающего знака препинания"
e4 = next(item for item in failed.result.findings if item.code is ErrorCode.E4)
assert e4.source is FindingSource.GRAMMAR
assert failed.result.score < passed.result.score
def test_grammar_is_not_checked_without_criterion_or_on_dds_exercise():
calls: list[str] = []
off = score(ScoringSnapshot.of(card_state(grammar=False)), grammar_says(False, calls))
dds = score(ScoringSnapshot.of(dds_state()), grammar_says(False, calls))
assert calls == []
assert "description_grammar" not in [item.key for item in off.result.metrics]
assert "description_grammar" not in [item.key for item in dds.result.metrics]
def test_snapshot_is_read_only():
snapshot = ScoringSnapshot.of(card_state())
with pytest.raises(FrozenInstanceError):
snapshot.scenario = None
def test_card_fill_time_is_scored_from_snapshot_timers():
report = score(ScoringSnapshot.of(card_state(grammar=False)), grammar_says(True, []))
metric = next(item for item in report.result.metrics if item.key == "card_fill_time")
assert metric.passed
assert metric.fact == "0 с (на 180 с быстрее норматива)"
assert report.summary["card_fill_ms"] is not None
def test_dds_snapshot_scores_every_queue_card_and_leaves_state_untouched():
state = dds_state()
finish_module.score_live_dds_cards(state)
completed = list(state.desk.completed)
snapshot = ScoringSnapshot.of(state)
report = score(snapshot, grammar_says(True, []))
assert [card.scenario_id for card in report.cards] == ["fire-apartment-l2", "t20-2-stroke"]
assert [item.title for item in report.result.metrics] == [
item.title for card in completed for item in card.metrics
]
assert state.desk.completed == completed
def test_handoff_live_and_refreshed_reports_use_operator_scenario_and_same_score():
state = card_state(handoff=True)
active = state.desk.ordered()[1]
state.desk.open(active.card_id)
active.set_service_status(
active.dispatched_card.notify[0], ServiceStatus.ACCEPTED,
"Основание: доклад.\nСведения: принято.",
)
# Предусловие ошибки: к завершению активна не карточка курсанта.
assert state.desk.active.scenario.id == "t01-1-fire-container"
assert ScoringSnapshot.of(state).scenario.id == "fire-apartment-l2"
async def live_then_refreshed():
async with hub.operation(state.session_id):
await finish_module.finish(state.session_id, state)
live = dict(state.score["full_report"])
state.self_assessed = True
async with hub.operation(state.session_id):
await finish_module.refresh_archived_report(state.session_id, state)
return live, state.score["full_report"]
live, refreshed = asyncio.run(live_then_refreshed())
assert live["scenario_id"] == refreshed["scenario_id"] == "fire-apartment-l2"
assert live["score_auto"] == refreshed["score_auto"] == state.score["score_auto"]
assert live["metrics"] == refreshed["metrics"]