176 lines
6.8 KiB
Python
176 lines
6.8 KiB
Python
|
|
"""Пауза занятия с пульта преподавателя (lct-39).
|
|||
|
|
|
|||
|
|
Часы — управляемый монотонный счётчик: тест продвигает их сам, а не спит,
|
|||
|
|
проверяя ровно то, что требует карточка — пауза не считается в норматив,
|
|||
|
|
переживает чекпойнт и пишется в аудит.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
from datetime import timedelta
|
|||
|
|
from pathlib import Path
|
|||
|
|
from uuid import uuid4
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from app.api.ws import control as control_ws
|
|||
|
|
from app.domain.events import Exercise, LessonCriteria, SessionMode
|
|||
|
|
from app.domain.timers import TimerCode
|
|||
|
|
from app.scenarios.loader import load_file
|
|||
|
|
from app.session import timers as timers_module
|
|||
|
|
from app.session.checkpoint import dump_state, load_state
|
|||
|
|
from app.session.hub import hub
|
|||
|
|
from app.session.state import SessionState, now_utc
|
|||
|
|
from app.session.store import LessonPaused, LessonResumed, MemorySessionStore
|
|||
|
|
from app.session.timers import Timer
|
|||
|
|
|
|||
|
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FakeClock:
|
|||
|
|
"""Управляемая замена `time.monotonic`: тест продвигает время явно."""
|
|||
|
|
|
|||
|
|
def __init__(self, start: float = 1_000.0) -> None:
|
|||
|
|
self.now = start
|
|||
|
|
|
|||
|
|
def __call__(self) -> float:
|
|||
|
|
return self.now
|
|||
|
|
|
|||
|
|
def advance(self, seconds: float) -> None:
|
|||
|
|
self.now += seconds
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def clock(monkeypatch):
|
|||
|
|
fake = FakeClock()
|
|||
|
|
monkeypatch.setattr(timers_module.time, "monotonic", fake)
|
|||
|
|
return fake
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_timer_pause_freezes_elapsed_until_resume(clock):
|
|||
|
|
timer = Timer(code=TimerCode.DDS_ACK)
|
|||
|
|
timer.start(clock.now)
|
|||
|
|
clock.advance(10)
|
|||
|
|
|
|||
|
|
timer.pause(clock.now)
|
|||
|
|
frozen = timer.current_ms(clock.now)
|
|||
|
|
clock.advance(60) # долгая пауза — часы идут, таймер не должен
|
|||
|
|
|
|||
|
|
assert timer.current_ms(clock.now) == frozen == 10_000
|
|||
|
|
|
|||
|
|
timer.resume(clock.now)
|
|||
|
|
clock.advance(15)
|
|||
|
|
|
|||
|
|
assert timer.current_ms(clock.now) == 25_000
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_session_pause_excludes_wait_from_dds_ack_deadline(clock):
|
|||
|
|
"""Карточка: 30 с норматива. Пауза 60 с внутри окна не должна дать D1 —
|
|||
|
|
отметка приходит на 25-й секунде активного времени, а не 85-й реальной."""
|
|||
|
|
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, criteria=LessonCriteria(decision_time_limit_seconds=30),
|
|||
|
|
)
|
|||
|
|
state.timers.limits[TimerCode.DDS_ACK] = 30_000
|
|||
|
|
from app.session.dds import prepare_queue
|
|||
|
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
|||
|
|
card = state.desk.active
|
|||
|
|
|
|||
|
|
clock.advance(10)
|
|||
|
|
state.pause()
|
|||
|
|
clock.advance(60)
|
|||
|
|
state.resume()
|
|||
|
|
clock.advance(15)
|
|||
|
|
card.on_event("card.ack")
|
|||
|
|
|
|||
|
|
measured = card.timers.measured_ms(TimerCode.DDS_ACK)
|
|||
|
|
assert measured == 25_000
|
|||
|
|
assert measured <= card.timers.limits[TimerCode.DDS_ACK]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_pause_freezes_queue_arrival_and_resume_shifts_it(clock, monkeypatch):
|
|||
|
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
|||
|
|
second = scenario.model_copy(deep=True)
|
|||
|
|
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,
|
|||
|
|
)
|
|||
|
|
from app.session.dds import prepare_queue
|
|||
|
|
prepare_queue(state, [scenario, second], arrival_interval_seconds=30, max_waiting=3)
|
|||
|
|
|
|||
|
|
due_before = state.desk.next_arrival_at
|
|||
|
|
assert due_before is not None
|
|||
|
|
|
|||
|
|
base = now_utc()
|
|||
|
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base)
|
|||
|
|
state.pause()
|
|||
|
|
idle = timedelta(minutes=1)
|
|||
|
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
|
|||
|
|
state.resume()
|
|||
|
|
|
|||
|
|
# Следующая карточка не должна поступить раньше, чем простояла пауза.
|
|||
|
|
assert state.desk.next_arrival_at == due_before + idle
|
|||
|
|
assert state.desk.deliver_due(due_before + timedelta(seconds=1)) == 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_checkpoint_round_trip_keeps_pause_without_adding_downtime(clock):
|
|||
|
|
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,
|
|||
|
|
)
|
|||
|
|
from app.session.dds import prepare_queue
|
|||
|
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
|||
|
|
card = state.desk.active
|
|||
|
|
|
|||
|
|
clock.advance(5)
|
|||
|
|
state.pause()
|
|||
|
|
saved_at = now_utc()
|
|||
|
|
|
|||
|
|
payload = dump_state(state)
|
|||
|
|
# Процесс лежал минуту — по часам сервера, не по monotonic этого теста.
|
|||
|
|
restored_at = saved_at + timedelta(minutes=1)
|
|||
|
|
restored = load_state(payload, restored_at)
|
|||
|
|
|
|||
|
|
assert restored.paused is True
|
|||
|
|
restored_card = restored.desk.active
|
|||
|
|
restored_timer = restored_card.timers.timers[TimerCode.DDS_ACK]
|
|||
|
|
# Простоя в счёт не пошло: элапсед остался тем же, что был на паузе.
|
|||
|
|
assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
|
|||
|
|
assert restored_timer.started_at is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
|
|||
|
|
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, owner_login="teacher",
|
|||
|
|
)
|
|||
|
|
from app.session.dds import prepare_queue
|
|||
|
|
prepare_queue(state, [scenario.model_copy(deep=True)])
|
|||
|
|
|
|||
|
|
store = MemorySessionStore()
|
|||
|
|
monkeypatch.setitem(hub._sessions, state.session_id, state)
|
|||
|
|
monkeypatch.setattr(hub, "store", store)
|
|||
|
|
who = type("Who", (), {"login": "teacher", "role": type("R", (), {"value": "instructor"})()})()
|
|||
|
|
|
|||
|
|
async def run():
|
|||
|
|
async with hub.operation(state.session_id):
|
|||
|
|
await control_ws._command(
|
|||
|
|
state.session_id, type("E", (), {"type": "session.pause"})(), who,
|
|||
|
|
)
|
|||
|
|
async with hub.operation(state.session_id):
|
|||
|
|
await control_ws._command(
|
|||
|
|
state.session_id, type("E", (), {"type": "session.resume"})(), who,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
asyncio.run(run())
|
|||
|
|
|
|||
|
|
records = [record for _sid, batch in store.commits for record in batch]
|
|||
|
|
assert any(isinstance(record, LessonPaused) for record in records)
|
|||
|
|
assert any(isinstance(record, LessonResumed) for record in records)
|
|||
|
|
assert state.paused is False
|