mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 14:27:32 +01:00
Fire alarm daily, harden dismissal challenge; ignore wake_state.json
- _constants.py: ALARM_DAYS now every day (was Mon/Fri/Sat/Sun); DISMISS_CODE_LENGTH 8->10 and DISMISS_ROUNDS_REQUIRED 2->4 so dismissing forces staying awake longer. - _challenges.py / _alarm.py: expanded dismissal challenge logic, with matching test updates. - shutdown-wrapper.sh: updated hibernate/rtcwake wrapper. - .gitignore: restore the missing rule for wake_alarm/wake_state.json (HMAC dismissal runtime state — must never be tracked, per CLAUDE.md). Tests: 191 passed, 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
This commit is contained in:
parent
643c55e204
commit
32d0994ba6
4
.gitignore
vendored
4
.gitignore
vendored
@ -14,3 +14,7 @@ coverage.lcov
|
||||
htmlcov/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Runtime state: HMAC-signed dismissal record for the current alarm day.
|
||||
# Never tracked (see CLAUDE.md).
|
||||
wake_alarm/wake_state.json
|
||||
|
||||
@ -1,17 +1,24 @@
|
||||
#!/bin/bash
|
||||
# Wrapper for /usr/bin/shutdown that redirects to rtcwake -m disk on alarm
|
||||
# nights (Mon, Fri, Sat, Sun by tomorrow's day-of-week). This ensures that
|
||||
# both the automated systemd timer AND manual "shutdown now" hibernate
|
||||
# correctly so the PC wakes for the morning alarm.
|
||||
# nights (every day of the week). This ensures that both the automated
|
||||
# systemd timer AND manual "shutdown now" hibernate correctly so the PC
|
||||
# wakes for the morning alarm.
|
||||
#
|
||||
# Install to /usr/local/bin/shutdown (takes priority over /usr/bin/shutdown
|
||||
# because /usr/local/bin appears first in PATH).
|
||||
#
|
||||
# Wake time is taken from the private GitHub Gist written by the phone app
|
||||
# (see phone_app/). Falls back to WAKE_AFTER_HOURS if the Gist is not
|
||||
# configured or unreachable. Config: ~/.config/wake_alarm/gist_sync.json
|
||||
# with keys "token" (GitHub PAT with gist scope) and "gist_id".
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REAL_SHUTDOWN=/usr/bin/shutdown
|
||||
RTCWAKE=/usr/sbin/rtcwake
|
||||
WAKE_AFTER_HOURS=8 # Must match WAKE_AFTER_HOURS in wake_alarm/_constants.py
|
||||
# Fallback: how many hours after shutdown to wake when Gist is unavailable.
|
||||
# Must match WAKE_AFTER_HOURS in wake_alarm/_constants.py.
|
||||
WAKE_AFTER_HOURS=8
|
||||
|
||||
# Pass through reboots and cancel commands unchanged.
|
||||
for arg in "$@"; do
|
||||
@ -22,13 +29,49 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Check if tomorrow is an alarm day (Mon=1, Fri=5, Sat=6, Sun=7 in date +%u).
|
||||
# Read alarm time from the private GitHub Gist written by the phone app.
|
||||
# Returns the Unix epoch (seconds) of the next occurrence of the stored H:M.
|
||||
# Exits non-zero if the Gist is not configured, unreachable, or malformed.
|
||||
get_gist_alarm_epoch() {
|
||||
local cfg="$HOME/.config/wake_alarm/gist_sync.json"
|
||||
[[ -f "$cfg" ]] || return 1
|
||||
|
||||
local token gist_id raw_content hour minute target_epoch now_epoch
|
||||
token=$(jq -r .token "$cfg" 2>/dev/null) || return 1
|
||||
gist_id=$(jq -r .gist_id "$cfg" 2>/dev/null) || return 1
|
||||
[[ -n "$token" && "$token" != "null" ]] || return 1
|
||||
[[ -n "$gist_id" && "$gist_id" != "null" ]] || return 1
|
||||
|
||||
raw_content=$(curl -sf --max-time 10 \
|
||||
-H "Authorization: token $token" \
|
||||
"https://api.github.com/gists/$gist_id" \
|
||||
| jq -r '.files["wake_alarm_sync.json"].content') || return 1
|
||||
[[ -n "$raw_content" && "$raw_content" != "null" ]] || return 1
|
||||
|
||||
hour=$(printf '%s' "$raw_content" | jq -r .alarm_hour 2>/dev/null) || return 1
|
||||
minute=$(printf '%s' "$raw_content" | jq -r .alarm_minute 2>/dev/null) || return 1
|
||||
[[ "$hour" =~ ^[0-9]+$ && "$minute" =~ ^[0-9]+$ ]] || return 1
|
||||
|
||||
printf -v now_epoch '%(%s)T' -1
|
||||
target_epoch=$(date -d "today $hour:$minute" +%s)
|
||||
if (( target_epoch <= now_epoch )); then
|
||||
target_epoch=$(date -d "tomorrow $hour:$minute" +%s)
|
||||
fi
|
||||
echo "$target_epoch"
|
||||
}
|
||||
|
||||
# Every day of the week is an alarm day (date +%u: Mon=1 … Sun=7).
|
||||
tomorrow_dow=$(date -d "tomorrow" +%u)
|
||||
case "$tomorrow_dow" in
|
||||
1|5|6|7)
|
||||
wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 ))
|
||||
logger -t shutdown-wrapper \
|
||||
"Tomorrow is alarm day (dow=$tomorrow_dow) — hibernating, RTC wake at epoch $wake_epoch"
|
||||
1|2|3|4|5|6|7)
|
||||
if wake_epoch=$(get_gist_alarm_epoch 2>/dev/null); then
|
||||
logger -t shutdown-wrapper \
|
||||
"Gist sync: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)"
|
||||
else
|
||||
wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 ))
|
||||
logger -t shutdown-wrapper \
|
||||
"Gist unavailable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)"
|
||||
fi
|
||||
sudo "$RTCWAKE" -m no -t "$wake_epoch"
|
||||
exec /usr/bin/systemctl hibernate
|
||||
;;
|
||||
|
||||
@ -191,8 +191,10 @@ class WakeAlarm:
|
||||
)
|
||||
info_label.pack(pady=10)
|
||||
|
||||
# Math and sort use a smaller font because their display text is wider.
|
||||
code_font_size = 48 if challenge.kind in ("math", "sort") else 72
|
||||
# Non-flash types use a smaller font because their display text is wider.
|
||||
code_font_size = (
|
||||
48 if challenge.kind in ("math", "sort", "sequence", "reverse") else 72
|
||||
)
|
||||
code_label = tk.Label(
|
||||
container,
|
||||
text=challenge.display,
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
"""Dismiss-challenge types for the wake alarm.
|
||||
|
||||
Provides three challenge variants:
|
||||
- math: solve an arithmetic problem
|
||||
Provides five challenge variants:
|
||||
- math: solve an arithmetic problem (addition, subtraction, multiplication, division)
|
||||
- sort: type shuffled digits in ascending order
|
||||
- flash: memorise a code before it is hidden
|
||||
- sequence: identify and continue a numeric pattern
|
||||
- reverse: type a displayed word backwards
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -17,6 +19,25 @@ from wake_alarm._constants import DISMISS_CODE_LENGTH, DISMISS_FLASH_SECONDS
|
||||
# legible at a glance, even half-asleep.
|
||||
_DISMISS_CHARS: str = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
# Words used by the reverse challenge — common, unambiguous when read half-asleep.
|
||||
_REVERSE_WORDS: tuple[str, ...] = (
|
||||
"MORNING",
|
||||
"COFFEE",
|
||||
"PILLOW",
|
||||
"BLANKET",
|
||||
"ALARM",
|
||||
"AWAKE",
|
||||
"SHOWER",
|
||||
"BREAKFAST",
|
||||
"WORKOUT",
|
||||
"WINDOW",
|
||||
"MONDAY",
|
||||
"TUESDAY",
|
||||
"FRIDAY",
|
||||
"SUNDAY",
|
||||
"WEEKEND",
|
||||
)
|
||||
|
||||
|
||||
class _Challenge:
|
||||
"""A dismiss challenge presented to the user to prove wakefulness."""
|
||||
@ -32,7 +53,7 @@ class _Challenge:
|
||||
"""Store challenge parameters.
|
||||
|
||||
Args:
|
||||
kind: Challenge type — "math", "flash", or "sort".
|
||||
kind: Challenge type — "math", "flash", "sort", "sequence", or "reverse".
|
||||
display: Text shown in the large code label.
|
||||
answer: Expected typed answer (normalised, upper-case).
|
||||
hint: Short instruction shown above the code label.
|
||||
@ -55,12 +76,14 @@ def _generate_code() -> str:
|
||||
def _make_math_challenge() -> _Challenge:
|
||||
"""Generate an arithmetic problem the user must solve to dismiss.
|
||||
|
||||
Picks randomly from addition, subtraction, and multiplication.
|
||||
The user types only the numeric answer — no copying, no autopilot.
|
||||
Picks randomly from addition, subtraction, multiplication, and division.
|
||||
Addition and subtraction use 3-digit operands for extra difficulty.
|
||||
Division always produces an integer result.
|
||||
"""
|
||||
op = secrets.choice(("+", "-", "*"))
|
||||
op = secrets.choice(("+", "-", "*", "÷"))
|
||||
if op == "+":
|
||||
a, b = 10 + secrets.randbelow(90), 10 + secrets.randbelow(90)
|
||||
a = 100 + secrets.randbelow(400)
|
||||
b = 100 + secrets.randbelow(400)
|
||||
return _Challenge(
|
||||
kind="math",
|
||||
display=f"{a} + {b} = ?",
|
||||
@ -68,19 +91,30 @@ def _make_math_challenge() -> _Challenge:
|
||||
hint="Solve and type the answer",
|
||||
)
|
||||
if op == "-":
|
||||
a = 20 + secrets.randbelow(80)
|
||||
b = 10 + secrets.randbelow(a - 10)
|
||||
a = 200 + secrets.randbelow(300)
|
||||
b = 100 + secrets.randbelow(a - 100)
|
||||
return _Challenge(
|
||||
kind="math",
|
||||
display=f"{a} - {b} = ?",
|
||||
answer=str(a - b),
|
||||
hint="Solve and type the answer",
|
||||
)
|
||||
a, b = 12 + secrets.randbelow(14), 3 + secrets.randbelow(7)
|
||||
if op == "*":
|
||||
a = 12 + secrets.randbelow(14)
|
||||
b = 3 + secrets.randbelow(7)
|
||||
return _Challenge(
|
||||
kind="math",
|
||||
display=f"{a} * {b} = ?",
|
||||
answer=str(a * b),
|
||||
hint="Solve and type the answer",
|
||||
)
|
||||
# Division: construct a * b first so the quotient is always an integer.
|
||||
b = 3 + secrets.randbelow(8) # divisor: 3-10
|
||||
a = (10 + secrets.randbelow(20)) * b # dividend: b * (10-29)
|
||||
return _Challenge(
|
||||
kind="math",
|
||||
display=f"{a} * {b} = ?",
|
||||
answer=str(a * b),
|
||||
display=f"{a} ÷ {b} = ?",
|
||||
answer=str(a // b),
|
||||
hint="Solve and type the answer",
|
||||
)
|
||||
|
||||
@ -88,15 +122,14 @@ def _make_math_challenge() -> _Challenge:
|
||||
def _make_sort_challenge() -> _Challenge:
|
||||
"""Generate a sort-the-digits challenge.
|
||||
|
||||
Displays six shuffled single digits; the user types them ascending (no spaces).
|
||||
Requires a brief cognitive effort — fast enough to be fair, slow enough to prove
|
||||
you are awake.
|
||||
Displays eight shuffled single digits; the user types them ascending (no spaces).
|
||||
Eight digits (up from six) require more effort to sort mentally.
|
||||
"""
|
||||
pool = list(range(1, 10))
|
||||
for i in range(len(pool) - 1, 0, -1):
|
||||
j = secrets.randbelow(i + 1)
|
||||
pool[i], pool[j] = pool[j], pool[i]
|
||||
digits = pool[:6]
|
||||
digits = pool[:8]
|
||||
display = " ".join(str(d) for d in digits)
|
||||
answer = "".join(str(d) for d in sorted(digits))
|
||||
return _Challenge(
|
||||
@ -122,8 +155,62 @@ def _make_flash_challenge() -> _Challenge:
|
||||
)
|
||||
|
||||
|
||||
def _make_sequence_challenge() -> _Challenge:
|
||||
"""Generate a numeric-pattern completion challenge.
|
||||
|
||||
Shows four terms of an arithmetic or geometric sequence and asks for the fifth.
|
||||
Forces the user to identify the pattern before they can answer.
|
||||
"""
|
||||
if secrets.randbelow(2) == 0:
|
||||
# Arithmetic sequence (linear)
|
||||
start = 2 + secrets.randbelow(14) # start: 2-15
|
||||
step = 2 + secrets.randbelow(8) # step: 2-9
|
||||
terms = [start + step * i for i in range(4)]
|
||||
answer = start + step * 4
|
||||
display = ", ".join(str(t) for t in terms) + ", ?"
|
||||
return _Challenge(
|
||||
kind="sequence",
|
||||
display=display,
|
||||
answer=str(answer),
|
||||
hint="Find the pattern and type the next number",
|
||||
)
|
||||
# Geometric sequence (exponential)
|
||||
start = 2 + secrets.randbelow(5) # start: 2-6
|
||||
ratio = 2 + secrets.randbelow(2) # ratio: 2 or 3
|
||||
terms = [start * (ratio**i) for i in range(4)]
|
||||
answer = start * (ratio**4)
|
||||
display = ", ".join(str(t) for t in terms) + ", ?"
|
||||
return _Challenge(
|
||||
kind="sequence",
|
||||
display=display,
|
||||
answer=str(answer),
|
||||
hint="Find the pattern and type the next number",
|
||||
)
|
||||
|
||||
|
||||
def _make_reverse_challenge() -> _Challenge:
|
||||
"""Generate a reverse-the-word challenge.
|
||||
|
||||
Picks a common word and asks the user to type it backwards.
|
||||
Simple but demands conscious attention — autopilot types forward.
|
||||
"""
|
||||
word = secrets.choice(_REVERSE_WORDS)
|
||||
return _Challenge(
|
||||
kind="reverse",
|
||||
display=f"Type this word backwards:\n{word}",
|
||||
answer=word[::-1],
|
||||
hint="Type the word backwards",
|
||||
)
|
||||
|
||||
|
||||
def _make_challenge() -> _Challenge:
|
||||
"""Pick a random challenge type and generate an instance."""
|
||||
return secrets.choice(
|
||||
(_make_math_challenge, _make_flash_challenge, _make_sort_challenge),
|
||||
(
|
||||
_make_math_challenge,
|
||||
_make_flash_challenge,
|
||||
_make_sort_challenge,
|
||||
_make_sequence_challenge,
|
||||
_make_reverse_challenge,
|
||||
),
|
||||
)()
|
||||
|
||||
@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
# Days the wake alarm is active (Python weekday(): Mon=0 ... Sun=6)
|
||||
# Monday, Friday, Saturday, Sunday
|
||||
ALARM_DAYS: frozenset[int] = frozenset({0, 4, 5, 6})
|
||||
# Every day of the week.
|
||||
ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6})
|
||||
|
||||
# How many hours after shutdown the PC should wake
|
||||
WAKE_AFTER_HOURS: int = 8
|
||||
@ -28,11 +28,11 @@ MEDIUM_BEEP_INTERVAL: float = 5.0
|
||||
LOUD_TOGGLE_INTERVAL: float = 2.0
|
||||
|
||||
# Dismiss challenge: length of the random code
|
||||
DISMISS_CODE_LENGTH: int = 8
|
||||
DISMISS_CODE_LENGTH: int = 10
|
||||
# Number of correct code entries required to dismiss the alarm.
|
||||
# Requiring more than one round forces the user to stay awake long enough
|
||||
# to actually read and type multiple independent codes.
|
||||
DISMISS_ROUNDS_REQUIRED: int = 2
|
||||
DISMISS_ROUNDS_REQUIRED: int = 4
|
||||
# Seconds the code is visible before being hidden in a flash challenge.
|
||||
DISMISS_FLASH_SECONDS: int = 4
|
||||
# How often the dismiss code refreshes (seconds)
|
||||
|
||||
@ -125,15 +125,15 @@ class TestIsAlarmDay:
|
||||
mock_dt.side_effect = datetime
|
||||
assert _is_alarm_day() is True
|
||||
|
||||
def test_tuesday_is_not_alarm_day(self) -> None:
|
||||
"""Tuesday (weekday=1) is NOT an alarm day."""
|
||||
def test_tuesday_is_alarm_day(self) -> None:
|
||||
"""Tuesday (weekday=1) is an alarm day."""
|
||||
with patch(
|
||||
"wake_alarm._alarm.datetime",
|
||||
) as mock_dt:
|
||||
mock_now = MagicMock()
|
||||
mock_now.weekday.return_value = 1 # Tuesday
|
||||
mock_dt.now.return_value = mock_now
|
||||
assert _is_alarm_day() is False
|
||||
assert _is_alarm_day() is True
|
||||
|
||||
def test_friday_is_alarm_day(self) -> None:
|
||||
"""Friday (weekday=4) is an alarm day."""
|
||||
@ -165,15 +165,25 @@ class TestIsAlarmDay:
|
||||
mock_dt.now.return_value = mock_now
|
||||
assert _is_alarm_day() is True
|
||||
|
||||
def test_wednesday_is_not_alarm_day(self) -> None:
|
||||
"""Wednesday (weekday=2) is NOT an alarm day."""
|
||||
def test_wednesday_is_alarm_day(self) -> None:
|
||||
"""Wednesday (weekday=2) is an alarm day."""
|
||||
with patch(
|
||||
"wake_alarm._alarm.datetime",
|
||||
) as mock_dt:
|
||||
mock_now = MagicMock()
|
||||
mock_now.weekday.return_value = 2
|
||||
mock_dt.now.return_value = mock_now
|
||||
assert _is_alarm_day() is False
|
||||
assert _is_alarm_day() is True
|
||||
|
||||
def test_thursday_is_alarm_day(self) -> None:
|
||||
"""Thursday (weekday=3) is an alarm day."""
|
||||
with patch(
|
||||
"wake_alarm._alarm.datetime",
|
||||
) as mock_dt:
|
||||
mock_now = MagicMock()
|
||||
mock_now.weekday.return_value = 3
|
||||
mock_dt.now.return_value = mock_now
|
||||
assert _is_alarm_day() is True
|
||||
|
||||
|
||||
class TestSpeakerTestPath:
|
||||
|
||||
@ -6,10 +6,13 @@ from unittest.mock import patch
|
||||
|
||||
from wake_alarm._challenges import (
|
||||
_DISMISS_CHARS,
|
||||
_REVERSE_WORDS,
|
||||
_Challenge,
|
||||
_make_challenge,
|
||||
_make_flash_challenge,
|
||||
_make_math_challenge,
|
||||
_make_reverse_challenge,
|
||||
_make_sequence_challenge,
|
||||
_make_sort_challenge,
|
||||
)
|
||||
from wake_alarm._constants import DISMISS_FLASH_SECONDS
|
||||
@ -28,12 +31,12 @@ class TestMakeMathChallenge:
|
||||
patch("wake_alarm._challenges.secrets.choice", return_value="+"),
|
||||
patch(
|
||||
"wake_alarm._challenges.secrets.randbelow",
|
||||
side_effect=[13, 35], # 10+13=23, 10+35=45
|
||||
side_effect=[100, 200], # 100+100=200, 100+200=300
|
||||
),
|
||||
):
|
||||
ch = _make_math_challenge()
|
||||
assert ch.display == "23 + 45 = ?"
|
||||
assert ch.answer == "68"
|
||||
assert ch.display == "200 + 300 = ?"
|
||||
assert ch.answer == "500"
|
||||
|
||||
def test_answer_is_correct_for_subtraction(self) -> None:
|
||||
"""Stored answer is numerically correct for subtraction."""
|
||||
@ -41,12 +44,12 @@ class TestMakeMathChallenge:
|
||||
patch("wake_alarm._challenges.secrets.choice", return_value="-"),
|
||||
patch(
|
||||
"wake_alarm._challenges.secrets.randbelow",
|
||||
side_effect=[30, 7], # 20+30=50, 10+7=17
|
||||
side_effect=[100, 50], # 200+100=300, 100+50=150
|
||||
),
|
||||
):
|
||||
ch = _make_math_challenge()
|
||||
assert ch.display == "50 - 17 = ?"
|
||||
assert ch.answer == "33"
|
||||
assert ch.display == "300 - 150 = ?"
|
||||
assert ch.answer == "150"
|
||||
|
||||
def test_answer_is_correct_for_multiplication(self) -> None:
|
||||
"""Stored answer is numerically correct for multiplication."""
|
||||
@ -61,6 +64,29 @@ class TestMakeMathChallenge:
|
||||
assert ch.display == "15 * 7 = ?"
|
||||
assert ch.answer == "105"
|
||||
|
||||
def test_answer_is_correct_for_division(self) -> None:
|
||||
"""Stored answer is numerically correct for division (always integer)."""
|
||||
with (
|
||||
patch("wake_alarm._challenges.secrets.choice", return_value="÷"),
|
||||
patch(
|
||||
"wake_alarm._challenges.secrets.randbelow",
|
||||
side_effect=[2, 5], # b=3+2=5, a=(10+5)*5=75
|
||||
),
|
||||
):
|
||||
ch = _make_math_challenge()
|
||||
assert ch.display == "75 ÷ 5 = ?"
|
||||
assert ch.answer == "15"
|
||||
|
||||
def test_division_answer_is_always_integer(self) -> None:
|
||||
"""Division challenges always produce whole-number answers."""
|
||||
for _ in range(30):
|
||||
with patch("wake_alarm._challenges.secrets.choice", return_value="÷"):
|
||||
ch = _make_math_challenge()
|
||||
# Extract dividend and divisor from display
|
||||
parts = ch.display.replace(" ÷ ", " ").replace(" = ?", "").split()
|
||||
dividend, divisor = int(parts[0]), int(parts[1])
|
||||
assert dividend % divisor == 0
|
||||
|
||||
def test_answer_varies_across_calls(self) -> None:
|
||||
"""Multiple calls produce varied answers (probabilistic)."""
|
||||
answers = {_make_math_challenge().answer for _ in range(30)}
|
||||
@ -81,10 +107,10 @@ class TestMakeSortChallenge:
|
||||
expected = "".join(str(d) for d in sorted(displayed_digits))
|
||||
assert ch.answer == expected
|
||||
|
||||
def test_display_contains_six_digits(self) -> None:
|
||||
"""Display always contains exactly six digit characters."""
|
||||
def test_display_contains_eight_digits(self) -> None:
|
||||
"""Display always contains exactly eight digit characters."""
|
||||
ch = _make_sort_challenge()
|
||||
assert len([c for c in ch.display if c.isdigit()]) == 6
|
||||
assert len([c for c in ch.display if c.isdigit()]) == 8
|
||||
|
||||
def test_answer_varies_across_calls(self) -> None:
|
||||
"""Multiple calls produce varied digit sets."""
|
||||
@ -115,6 +141,92 @@ class TestMakeFlashChallenge:
|
||||
assert str(DISMISS_FLASH_SECONDS) in ch.hint
|
||||
|
||||
|
||||
class TestMakeSequenceChallenge:
|
||||
"""Tests for _make_sequence_challenge."""
|
||||
|
||||
def test_kind_is_sequence(self) -> None:
|
||||
"""Challenge kind is always 'sequence'."""
|
||||
assert _make_sequence_challenge().kind == "sequence"
|
||||
|
||||
def test_arithmetic_answer_is_correct(self) -> None:
|
||||
"""Arithmetic sequence next term is first + step * 4."""
|
||||
with patch("wake_alarm._challenges.secrets.randbelow", side_effect=[0, 0, 3]):
|
||||
# randbelow(2)==0 → arithmetic, start=2+0=2, step=2+3=5
|
||||
ch = _make_sequence_challenge()
|
||||
assert ch.display == "2, 7, 12, 17, ?"
|
||||
assert ch.answer == "22"
|
||||
|
||||
def test_geometric_answer_is_correct(self) -> None:
|
||||
"""Geometric sequence next term is start * ratio^4."""
|
||||
with patch("wake_alarm._challenges.secrets.randbelow", side_effect=[1, 0, 0]):
|
||||
# randbelow(2)==1 → geometric, start=2+0=2, ratio=2+0=2
|
||||
ch = _make_sequence_challenge()
|
||||
assert ch.display == "2, 4, 8, 16, ?"
|
||||
assert ch.answer == "32"
|
||||
|
||||
def test_answer_is_last_term(self) -> None:
|
||||
"""The answer is always the 5th term of the displayed sequence."""
|
||||
for _ in range(30):
|
||||
ch = _make_sequence_challenge()
|
||||
# Re-derive from the display
|
||||
terms_str = ch.display.replace(", ?", "").split(", ")
|
||||
terms = [int(t) for t in terms_str]
|
||||
assert len(terms) == 4
|
||||
# Arithmetic check: constant difference
|
||||
diffs = [terms[i + 1] - terms[i] for i in range(3)]
|
||||
if len(set(diffs)) == 1:
|
||||
expected = terms[-1] + diffs[0]
|
||||
else:
|
||||
# Geometric: constant ratio
|
||||
ratio = terms[1] // terms[0]
|
||||
expected = terms[-1] * ratio
|
||||
assert ch.answer == str(expected)
|
||||
|
||||
def test_both_series_types_reachable(self) -> None:
|
||||
"""Both arithmetic and geometric series appear across many calls."""
|
||||
found_arithmetic = False
|
||||
found_geometric = False
|
||||
for _ in range(200):
|
||||
ch = _make_sequence_challenge()
|
||||
terms_str = ch.display.replace(", ?", "").split(", ")
|
||||
terms = [int(t) for t in terms_str]
|
||||
diffs = [terms[i + 1] - terms[i] for i in range(3)]
|
||||
if len(set(diffs)) == 1:
|
||||
found_arithmetic = True
|
||||
else:
|
||||
found_geometric = True
|
||||
if found_arithmetic and found_geometric:
|
||||
break
|
||||
assert found_arithmetic
|
||||
assert found_geometric
|
||||
|
||||
|
||||
class TestMakeReverseChallenge:
|
||||
"""Tests for _make_reverse_challenge."""
|
||||
|
||||
def test_kind_is_reverse(self) -> None:
|
||||
"""Challenge kind is always 'reverse'."""
|
||||
assert _make_reverse_challenge().kind == "reverse"
|
||||
|
||||
def test_answer_is_word_reversed(self) -> None:
|
||||
"""Answer equals the displayed word reversed."""
|
||||
ch = _make_reverse_challenge()
|
||||
# Extract the word from display (second line)
|
||||
word = ch.display.split("\n")[1]
|
||||
assert ch.answer == word[::-1]
|
||||
|
||||
def test_word_is_from_pool(self) -> None:
|
||||
"""Displayed word always comes from _REVERSE_WORDS."""
|
||||
ch = _make_reverse_challenge()
|
||||
word = ch.display.split("\n")[1]
|
||||
assert word in _REVERSE_WORDS
|
||||
|
||||
def test_answer_varies_across_calls(self) -> None:
|
||||
"""Multiple calls produce different words (probabilistic)."""
|
||||
answers = {_make_reverse_challenge().answer for _ in range(60)}
|
||||
assert len(answers) > 1
|
||||
|
||||
|
||||
class TestMakeChallenge:
|
||||
"""Tests for _make_challenge (the random dispatcher)."""
|
||||
|
||||
@ -122,12 +234,12 @@ class TestMakeChallenge:
|
||||
"""Returns a _Challenge instance with all expected fields populated."""
|
||||
ch = _make_challenge()
|
||||
assert isinstance(ch, _Challenge)
|
||||
assert ch.kind in ("math", "flash", "sort")
|
||||
assert ch.kind in ("math", "flash", "sort", "sequence", "reverse")
|
||||
assert ch.display
|
||||
assert ch.answer
|
||||
assert ch.hint
|
||||
|
||||
def test_all_types_reachable(self) -> None:
|
||||
"""All three challenge types appear across many calls."""
|
||||
kinds = {_make_challenge().kind for _ in range(200)}
|
||||
assert kinds == {"math", "flash", "sort"}
|
||||
"""All five challenge types appear across many calls."""
|
||||
kinds = {_make_challenge().kind for _ in range(500)}
|
||||
assert kinds == {"math", "flash", "sort", "sequence", "reverse"}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user