refactor: завершение занятия — одна операция end_session вместо четырёх копий в сокетах

This commit is contained in:
gglamer 2026-09-27 07:40:31 +00:00
commit f9b6fafb7c
6 changed files with 299 additions and 69 deletions

View file

@ -0,0 +1,233 @@
"""Завершение занятия — одна операция `end_session`.
Порядок шагов проверяется без websocket: фейковый хаб пишет журнал вызовов,
фейковый голос и фейковая оценка отмечают себя в том же журнале.
"""
import asyncio
import time
from pathlib import Path
from uuid import uuid4
import pytest
from app.domain.events import CallEndReason, Exercise, SessionMode
from app.domain.kio import KIO
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
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.state import SessionState
from app.session.store import LessonEnded, MemorySessionStore
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
def fire():
return load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
def dds_state() -> SessionState:
"""Пульт ДДС на две карточки, вторая открыта: `DDS_WORK` идёт."""
first = fire()
scenarios = [first.model_copy(deep=True, update={"id": f"desk-{index}"})
for index in range(2)]
state = SessionState(
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_queue(state, scenarios)
card = state.desk.ordered()[1]
state.desk.open(card.card_id)
# Карточка принята: три минуты отработки идут с этого момента.
card.on_event("card.ack")
card.on_event("dds.open")
return state
def card_state(*, handoff: bool = False) -> SessionState:
scenario = fire()
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING,
exercise=Exercise.CARD, handoff_to_dds=handoff, scenario=scenario,
)
state.kio = KIO(address="улица Ленина, 14", notify=["Служба 101"])
if handoff:
state.dispatch()
prepare_handoff_queue(state, [])
return state
def work_stopped(state) -> bool:
return all(
card.timers.measured_ms(TimerCode.DDS_WORK) is not None
for card in state.desk.cards.values()
if TimerCode.DDS_WORK in card.timers.timers
)
class FakeHub:
def __init__(self, log: list, state) -> None:
self.log = log
self.state = state
def stop_ticker(self, _session_id) -> None:
self.log.append(("ticker", work_stopped(self.state)))
def record(self, _session_id, record) -> None:
self.log.append(("record", type(record).__name__))
def to_station(self, _session_id, event) -> None:
self.log.append(("station", event.type))
def to_trainee(self, _session_id, event) -> None:
self.log.append(("trainee", event.type))
def to_observers(self, _session_id, event) -> None:
self.log.append(("observers", event.type))
class FakeVoice:
def __init__(self, log: list, state) -> None:
self.log = log
self.state = state
async def close(self) -> None:
self.log.append(("voice", work_stopped(self.state)))
@pytest.fixture
def fake(monkeypatch):
def install(state):
log: list = []
monkeypatch.setattr(finish_module, "hub", FakeHub(log, state))
async def fake_finish(_session_id, finished):
log.append(("finish", finished.ended))
finished.score = {"score": 1.0}
monkeypatch.setattr(finish_module, "finish", fake_finish)
return log
return install
def test_end_session_runs_steps_in_fixed_order(fake):
state = dds_state()
log = fake(state)
state.voice = FakeVoice(log, state)
asyncio.run(finish_module.end_session(state.session_id, state, CallEndReason.INSTRUCTOR))
assert state.ended and state.end_reason is CallEndReason.INSTRUCTOR
# таймеры → голос → тикер → журнал → оценка → рассылка
assert log == [
("voice", True),
("ticker", True),
("record", "LessonEnded"),
("finish", True),
("observers", "session.ended"),
("station", "session.ended"),
("observers", "score.ready"),
("station", "score.ready"),
]
def test_card_without_handoff_tells_trainee_and_releases_score(fake):
state = card_state()
log = fake(state)
asyncio.run(finish_module.end_session(state.session_id, state, CallEndReason.COMPLETE))
events = [entry for entry in log if entry[0] in {"observers", "station", "trainee"}]
assert events == [
("observers", "session.ended"),
("trainee", "call.ended"),
("observers", "score.ready"),
("trainee", "score.ready"),
]
def test_card_handed_to_dds_ends_on_station(fake):
state = card_state(handoff=True)
log = fake(state)
asyncio.run(finish_module.end_session(state.session_id, state, CallEndReason.INSTRUCTOR))
events = [entry for entry in log if entry[0] in {"observers", "station", "trainee"}]
assert events == [
("observers", "session.ended"),
("station", "session.ended"),
("observers", "score.ready"),
("trainee", "score.ready"),
("station", "score.ready"),
]
def test_card_stopped_before_submit_stops_fill_timer(fake):
state = card_state()
state.on_event("card.start")
fake(state)
asyncio.run(finish_module.end_session(state.session_id, state, CallEndReason.INSTRUCTOR))
assert state.timers.measured_ms(TimerCode.CARD_FILL) is not None
def test_second_end_session_writes_and_sends_nothing(fake):
state = dds_state()
log = fake(state)
asyncio.run(finish_module.end_session(state.session_id, state, CallEndReason.COMPLETE))
ended_at = state.ended_at
log.clear()
asyncio.run(finish_module.end_session(state.session_id, state, CallEndReason.INSTRUCTOR))
assert log == []
assert state.ended_at == ended_at
assert state.end_reason is CallEndReason.COMPLETE
@pytest.fixture
def frozen_clock(monkeypatch):
clock = {"now": time.monotonic()}
monkeypatch.setattr(time, "monotonic", lambda: clock["now"])
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())
return clock
def test_instructor_stop_in_dds_stops_work_timer_and_keeps_score(frozen_clock):
before, after = dds_state(), dds_state()
frozen_clock["now"] += 95
async def old_stop(state):
# Прежний `_stop`: оценка брала время отработки по `current_ms()`.
hub.register(state)
async with hub.operation(state.session_id):
ended_at = state.end(CallEndReason.INSTRUCTOR)
hub.record(state.session_id, LessonEnded(ended_at, CallEndReason.INSTRUCTOR.value))
await finish_module.finish(state.session_id, state)
async def new_stop(state):
hub.register(state)
async with hub.operation(state.session_id):
await finish_module.end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
asyncio.run(old_stop(before))
asyncio.run(new_stop(after))
active = after.desk.cards[after.desk.active_id]
assert active.timers.measured_ms(TimerCode.DDS_WORK) == 95_000
assert after.score["score_auto"] == before.score["score_auto"]
assert after.score["metrics"] == before.score["metrics"]
assert after.score["findings"] == before.score["findings"]