167 lines
6.9 KiB
Python
167 lines
6.9 KiB
Python
|
|
"""Промежуточное состояние занятия переживает смену backend-процесса."""
|
|||
|
|
|
|||
|
|
from datetime import UTC, datetime, timedelta
|
|||
|
|
from pathlib import Path
|
|||
|
|
from uuid import uuid4
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from app.domain.events import Exercise, LessonCriteria, SessionMode
|
|||
|
|
from app.domain.statuses import PhoneCallPending, ServiceStatus
|
|||
|
|
from app.domain.timers import TimerCode
|
|||
|
|
from app.scenarios.loader import load_file
|
|||
|
|
from app.scoring.grammar import basic_check
|
|||
|
|
from app.session.checkpoint import dump_state, load_state
|
|||
|
|
from app.session.dds import deliver_due_cards, prepare_queue
|
|||
|
|
from app.session.state import SessionState, now_utc
|
|||
|
|
|
|||
|
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def dds_state() -> SessionState:
|
|||
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|||
|
|
state = SessionState(
|
|||
|
|
session_id=uuid4(),
|
|||
|
|
scenario_id=scenario.id,
|
|||
|
|
scenario_title=scenario.title,
|
|||
|
|
level=scenario.level.value,
|
|||
|
|
mode=SessionMode.TRAINING,
|
|||
|
|
exercise=Exercise.DDS,
|
|||
|
|
scenario=scenario.model_copy(deep=True),
|
|||
|
|
dds_scenarios=[scenario.model_copy(deep=True)],
|
|||
|
|
required_fields=list(scenario.required_fields),
|
|||
|
|
trainee_name="Курсант для восстановления",
|
|||
|
|
attempt=2,
|
|||
|
|
criteria=LessonCriteria(
|
|||
|
|
decision_time_limit_seconds=45,
|
|||
|
|
allowed_errors=1,
|
|||
|
|
require_correct_grammar=True,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
state.timers.limits[TimerCode.DDS_ACK] = 45_000
|
|||
|
|
prepare_queue(state, state.dds_scenarios)
|
|||
|
|
service = state.notified_services()[0]
|
|||
|
|
state.set_service_status(service, ServiceStatus.ACCEPTED, author="диспетчер")
|
|||
|
|
state.crew_selected = state.crew_options()[0]
|
|||
|
|
state.crew_assignments[service] = state.crew_selected
|
|||
|
|
state.phone_pending = PhoneCallPending(
|
|||
|
|
service=service, crew=state.crew_selected, phase="dispatched"
|
|||
|
|
)
|
|||
|
|
state.reply_text = "Сообщение принято, бригада направлена."
|
|||
|
|
state.reply_grammar = basic_check(state.reply_text)
|
|||
|
|
state.reply_log.append((now_utc(), state.reply_text))
|
|||
|
|
state.dds_log.append(("crew.select", now_utc(), state.crew_selected))
|
|||
|
|
return state
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_active_dds_session_round_trips_without_losing_work():
|
|||
|
|
before = dds_state()
|
|||
|
|
payload = dump_state(before)
|
|||
|
|
restored = load_state(
|
|||
|
|
payload,
|
|||
|
|
datetime.now(UTC) - timedelta(seconds=2),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert restored.session_id == before.session_id
|
|||
|
|
assert restored.exercise is Exercise.DDS
|
|||
|
|
assert restored.criteria.decision_time_limit_seconds == 45
|
|||
|
|
assert restored.dispatched_card == before.dispatched_card
|
|||
|
|
assert restored.status_log == before.status_log
|
|||
|
|
assert restored.crew_selected == before.crew_selected
|
|||
|
|
assert restored.crew_assignments == before.crew_assignments
|
|||
|
|
assert restored.phone_pending == before.phone_pending
|
|||
|
|
assert restored.reply_text == before.reply_text
|
|||
|
|
assert restored.reply_grammar == before.reply_grammar
|
|||
|
|
assert restored.dds_scenarios[0].id == before.scenario_id
|
|||
|
|
# Время простоя backend входит в норматив, а не обнуляет таймер.
|
|||
|
|
timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK)
|
|||
|
|
assert timer.elapsed_ms >= 1_900
|
|||
|
|
assert timer.limit_ms == 45_000
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_checkpoint_rejects_unknown_format_version():
|
|||
|
|
payload = dump_state(dds_state())
|
|||
|
|
payload["version"] = 999
|
|||
|
|
with pytest.raises(ValueError, match="версия"):
|
|||
|
|
load_state(payload, datetime.now(UTC))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
|
|||
|
|
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|||
|
|
second = first.model_copy(
|
|||
|
|
deep=True,
|
|||
|
|
update={"id": "checkpoint-second", "title": "Вторая карточка восстановления"},
|
|||
|
|
)
|
|||
|
|
state = SessionState(
|
|||
|
|
session_id=uuid4(),
|
|||
|
|
scenario_id=first.id,
|
|||
|
|
scenario_title=first.title,
|
|||
|
|
level=first.level.value,
|
|||
|
|
mode=SessionMode.TRAINING,
|
|||
|
|
exercise=Exercise.DDS,
|
|||
|
|
dds_scenarios=[first, second],
|
|||
|
|
)
|
|||
|
|
prepare_queue(state, state.dds_scenarios)
|
|||
|
|
first_id = state.dispatched_card.card_id
|
|||
|
|
first_service = state.managed_services()[0]
|
|||
|
|
state.set_service_status(first_service, ServiceStatus.ACCEPTED)
|
|||
|
|
state.on_event("card.ack")
|
|||
|
|
second_id = state.dds_live_cards[1].card_id
|
|||
|
|
assert state.activate_dds_card(second_id)
|
|||
|
|
|
|||
|
|
restored = load_state(
|
|||
|
|
dump_state(state),
|
|||
|
|
datetime.now(UTC) - timedelta(seconds=2),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert restored.dds_active_card_id == second_id
|
|||
|
|
assert restored.dispatched_card.card_id == second_id
|
|||
|
|
assert len(restored.dds_live_cards) == 2
|
|||
|
|
first_restored = next(item for item in restored.dds_live_cards if item.card_id == first_id)
|
|||
|
|
second_restored = next(item for item in restored.dds_live_cards if item.card_id == second_id)
|
|||
|
|
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
|
|||
|
|
assert first_restored.timers.measured_ms(TimerCode.DDS_ACK) is not None
|
|||
|
|
second_timer = next(
|
|||
|
|
item for item in second_restored.timers.snapshot() if item.code is TimerCode.DDS_ACK
|
|||
|
|
)
|
|||
|
|
assert second_timer.elapsed_ms >= 1_900
|
|||
|
|
assert second_timer.stopped is False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_delivering_next_dds_card_does_not_clear_previous_card_state():
|
|||
|
|
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|||
|
|
scenarios = [
|
|||
|
|
first.model_copy(
|
|||
|
|
deep=True,
|
|||
|
|
update={"id": f"scheduled-checkpoint-{index}", "title": f"Карточка {index}"},
|
|||
|
|
)
|
|||
|
|
for index in range(3)
|
|||
|
|
]
|
|||
|
|
state = SessionState(
|
|||
|
|
session_id=uuid4(),
|
|||
|
|
scenario_id=scenarios[0].id,
|
|||
|
|
scenario_title=scenarios[0].title,
|
|||
|
|
level=scenarios[0].level.value,
|
|||
|
|
mode=SessionMode.TRAINING,
|
|||
|
|
exercise=Exercise.DDS,
|
|||
|
|
dds_scenarios=scenarios,
|
|||
|
|
)
|
|||
|
|
prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
|
|||
|
|
first_id = state.dds_live_cards[0].card_id
|
|||
|
|
service = state.managed_services()[0]
|
|||
|
|
state.set_service_status(service, ServiceStatus.ACCEPTED)
|
|||
|
|
state.capture_active_dds()
|
|||
|
|
|
|||
|
|
assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1
|
|||
|
|
first = next(card for card in state.dds_live_cards if card.card_id == first_id)
|
|||
|
|
assert first.status_log[-1].status is ServiceStatus.ACCEPTED
|
|||
|
|
second = next(card for card in state.dds_live_cards if card.original_index == 1)
|
|||
|
|
assert state.activate_dds_card(second.card_id)
|
|||
|
|
|
|||
|
|
restored = load_state(dump_state(state), now_utc())
|
|||
|
|
first_restored = next(card for card in restored.dds_live_cards if card.card_id == first_id)
|
|||
|
|
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
|
|||
|
|
assert restored.dds_active_card_id == second.card_id
|
|||
|
|
assert len(restored.dds_live_cards) == 2
|
|||
|
|
assert restored.dds_next_scenario_index == 2
|
|||
|
|
assert restored.dds_next_arrival_at is not None
|