wake-alarm/wake_alarm/tests/test_escalation.py
Krzysztof kuhy Rudnicki aa01e0e3e6 Phase 2b: add the 5-minute audio failsafe, and retune escalation to fit it
The failsafe silences AUDIO ONLY at 300 s. The lock, the typed challenge and
the workout gate all persist, so waiting it out in bed is not an escape --
it only stops a ring nobody is present to dismiss from sounding forever.

Adding it exposed two collisions that would each have quietly defeated the
alarm:

1. The escalation ladder ended outside the audible window. PHASE_SOFT_END
   was 5 minutes and PHASE_MEDIUM_END 15, so with the failsafe at 5 minutes
   the alarm was silenced before it ever left its SOFTEST rung -- the medium
   and loud phases became unreachable code. Printed timeline before the fix:

       0s soft 440Hz ... 299s soft 440Hz | 300s failsafe-silent

   Phases are now 1 and 2 minutes, so the alarm spends 3 of its 5 audible
   minutes on the loud alternating rung, which is what makes lying in bed
   for five minutes unattractive.

2. ESCAPE_OFFER_AFTER_SECONDS was also 300, so the escape hatch appeared in
   the same instant the sound stopped -- at its most tempting, precisely when
   the alarm had just stopped being annoying. Moved to 900.

Both invariants are now locked in by tests, so retuning one constant without
the other fails instead of silently degrading the alarm.

Also fixes a latent test bug this surfaced: TestBeepLoopPhases anchored
elapsed time to "one minute past the previous boundary", so a phase retune
made it miss its rung, never set _stop_beep, and spin forever calling the
REAL audio stack -- hanging the whole suite rather than failing. It now uses
the midpoint of the band and patches every rung, so a mistuned boundary fails
fast.

test_alarm_part3.py split (372 -> 195) for the 250-line gate.

413 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-26 23:17:41 +02:00

92 lines
3.1 KiB
Python

"""Tests for the beep escalation ladder and the audio failsafe."""
from __future__ import annotations
import pytest
from wake_alarm._constants import (
ESCAPE_OFFER_AFTER_SECONDS,
FAILSAFE_SILENCE_SECONDS,
PHASE_MEDIUM_END,
PHASE_SOFT_END,
)
from wake_alarm._escalation import (
LOUD_HIGH_FREQUENCY,
LOUD_LOW_FREQUENCY,
MEDIUM_FREQUENCY,
SOFT_FREQUENCY,
plan_for,
)
def test_starts_on_the_soft_rung() -> None:
"""The first seconds are the gentlest tone."""
plan = plan_for(0.0)
assert plan.frequency == SOFT_FREQUENCY
assert plan.phase == "soft"
def test_escalates_to_medium() -> None:
"""Crossing the soft boundary raises the pitch."""
assert plan_for(PHASE_SOFT_END * 60).frequency == MEDIUM_FREQUENCY
def test_escalates_to_loud() -> None:
"""Crossing the medium boundary reaches the loud rung."""
assert plan_for(PHASE_MEDIUM_END * 60).phase == "loud"
def test_loud_phase_alternates_between_two_tones() -> None:
"""A changing pitch stays salient where a constant one is tuned out."""
seen = {plan_for(PHASE_MEDIUM_END * 60 + offset).frequency for offset in range(20)}
assert seen == {LOUD_LOW_FREQUENCY, LOUD_HIGH_FREQUENCY}
def test_intervals_shorten_as_it_escalates() -> None:
"""Each rung beeps more often than the last."""
soft = plan_for(0.0).interval
medium = plan_for(PHASE_SOFT_END * 60).interval
loud = plan_for(PHASE_MEDIUM_END * 60).interval
assert soft > medium > loud
def test_failsafe_silences_audio_at_the_threshold() -> None:
"""At the failsafe the tone stops."""
plan = plan_for(FAILSAFE_SILENCE_SECONDS)
assert plan.frequency is None
assert plan.silenced is True
def test_audio_still_sounds_just_before_the_failsafe() -> None:
"""The boundary is exclusive; a moment earlier the alarm is still loud."""
plan = plan_for(FAILSAFE_SILENCE_SECONDS - 1)
assert plan.frequency is not None
assert plan.silenced is False
@pytest.mark.parametrize("extra", [1, 60, 600, 3600])
def test_stays_silent_after_the_failsafe(extra: int) -> None:
"""Silence is permanent for the rest of the ring; it never resumes."""
assert plan_for(FAILSAFE_SILENCE_SECONDS + extra).silenced is True
def test_reaches_the_loud_phase_before_the_failsafe() -> None:
"""The whole point: the alarm must get maximally annoying before it stops.
A regression here would silently reduce the alarm to its softest tone,
which is exactly the bug this ordering exists to prevent.
"""
assert PHASE_MEDIUM_END * 60 < FAILSAFE_SILENCE_SECONDS
def test_spends_most_of_the_ring_on_the_loud_rung() -> None:
"""Loud should dominate the audible window, not be a brief tail."""
loud_seconds = FAILSAFE_SILENCE_SECONDS - PHASE_MEDIUM_END * 60
assert loud_seconds >= FAILSAFE_SILENCE_SECONDS / 2
def test_escape_hatch_does_not_open_when_the_sound_stops() -> None:
"""Offering the hatch exactly as the alarm goes quiet makes it too easy."""
assert ESCAPE_OFFER_AFTER_SECONDS != FAILSAFE_SILENCE_SECONDS
assert ESCAPE_OFFER_AFTER_SECONDS > FAILSAFE_SILENCE_SECONDS