Make the alarm fully automatic and un-weakenable (08:00, earlier-only)

The alarm was opt-in: it only armed when the user tapped "Set Alarm & Sync to
PC", so it could be defeated by simply not tapping it, or by cancelling it the
night before. It is now automatic and there is no lever a half-asleep user can
pull that buys them sleep.

- AlarmPolicy bakes the schedule in at 08:00 daily. The only edit the UI
  offers is moving the NEXT alarm earlier; setEarlier() refuses anything at or
  after the baseline, and refuses to relax an active override. Earlier-only is
  the whole design: every available change costs the user sleep.
- The ratchet resets after each fire, so an early start is one-shot. A one-way
  ratchet with no reset is a trap, not a defence — the first 05:00 would
  otherwise be permanent with no way back short of reinstalling.
- load() re-validates the stored value and ignores anything at or after the
  baseline. SharedPreferences is not a trust boundary.
- No cancel. The Kotlin handler is deleted, not just the Dart wrapper, so the
  capability genuinely does not exist rather than being one invokeMethod away.
- Arming happens in main()'s init seam and via AlarmWatchdog, a daily
  self-rescheduling check that re-arms if nothing is scheduled — closing the
  gap where a force-stop or a failed re-arm goes unnoticed until the morning
  it does not ring. BootReceiver re-establishes it, since setInexactRepeating
  does not survive a reboot.
- Publishing to the PC is automatic too; it used to need the same tap.

PC half, so the two agree with the network down:
- FALLBACK_ALARM_HOUR 13 -> 8, matching the phone's baseline. At 13:00 a
  Firebase outage silently moved the alarm five hours LATER — the one
  direction the design refuses to allow.
- _trigger._clamp_to_baseline() refuses any resolved alarm later than the
  baseline, whatever its source. Without it the PC would faithfully reproduce
  a stale or tampered remote value and the forbidden direction stays reachable
  through sync.

Tests: 465 Python at 100% branch coverage, 79 Dart. Both ratchet directions
are covered, per the standing rule about monotonic counters. test_sync.py and
test_trigger.py were split (payload, token, CLI, clamp) to stay under the
250-line gate; the split lost indirect coverage of extract_hlc/newest_alarm,
which now have direct tests instead.

`python -m wake_alarm._sync --show` and `_trigger --dry-run` both re-run and
still print real output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
Krzysztof Rudnicki 2026-08-27 23:03:00 +02:00
parent f53003ee4d
commit 95be57b575
18 changed files with 891 additions and 293 deletions

View File

@ -63,6 +63,11 @@
<receiver
android:name=".AlarmReceiver"
android:exported="false"/>
<!-- Daily check that the alarm is still armed; not exported, only
this app's own repeating PendingIntent reaches it. -->
<receiver
android:name=".WatchdogReceiver"
android:exported="false"/>
<receiver
android:name=".BootReceiver"
android:exported="true">

View File

@ -24,14 +24,27 @@ object AlarmChannel {
fun handleCall(context: Context, call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"schedule" -> schedule(context, call, result)
"cancel" -> {
AlarmScheduler.cancel(context)
result.success(null)
}
// No "cancel": the alarm cannot be switched off. Removing the
// handler (rather than just the Dart wrapper) is what makes that
// real — otherwise the capability is one invokeMethod away.
// Called from the Dart init seam and after a dismiss: with no
// change-stream in the store, these are the only re-arm hooks
// besides BOOT_COMPLETED.
"rearm" -> result.success(AlarmScheduler.rearmFromStorage(context))
// Arms the fixed schedule and (re)starts the daily check. Called
// from the Dart init seam on every launch, so the alarm is armed
// by opening the app at all rather than by tapping anything.
"armSchedule" -> {
val hour = call.argument<Int>("hour")
val minute = call.argument<Int>("minute")
if (hour == null || minute == null) {
result.error("bad_args", "hour and minute are required", null)
} else {
AlarmScheduler.schedule(context, hour, minute)
AlarmWatchdog.schedule(context)
result.success(null)
}
}
"canUseFullScreenIntent" ->
result.success(AlarmScheduler.canUseFullScreenIntent(context))
"dismiss" -> {

View File

@ -0,0 +1,76 @@
package com.kuhy.wake_alarm_sync
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* Keeps the alarm armed without the user ever opening the app.
*
* The ring path already re-arms on boot and on dismiss, but both are *events*:
* if the app is force-stopped, or an update clears the pending intent, or a
* re-arm silently fails, nothing notices until the morning it does not ring.
* This is the periodic check that closes that gap — it runs daily, asks
* whether an alarm is actually scheduled, and schedules one if not.
*
* Deliberately self-rescheduling rather than WorkManager: the ring path takes
* no third-party dependency (see AlarmChannel), and AlarmManager is already
* present and understood here.
*/
object AlarmWatchdog {
private const val TAG = "WakeAlarmWatchdog"
/** Request code for the watchdog's own repeating wake-up. */
private const val REQUEST_CODE = 1002
/** How often to verify the alarm is still armed. */
private const val INTERVAL_MS = AlarmManager.INTERVAL_DAY
/** Starts (or refreshes) the periodic check. Safe to call repeatedly. */
fun schedule(context: Context) {
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// Inexact on purpose: this only has to happen *sometime* each day, and
// an inexact repeat costs no exact-alarm budget and survives Doze by
// being deferred rather than dropped.
manager.setInexactRepeating(
AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + INTERVAL_MS,
INTERVAL_MS,
pendingIntent(context),
)
}
/**
* Re-arms the alarm if nothing is currently scheduled.
*
* Re-arming unconditionally would be simpler, but would also reset a
* legitimately pending alarm every day; `rearmFromStorage` is idempotent
* for an unchanged time, so this stays cheap either way.
*/
fun verify(context: Context) {
val rearmed = AlarmScheduler.rearmFromStorage(context)
Log.i(TAG, "daily check: rearmed=$rearmed")
}
private fun pendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
REQUEST_CODE,
Intent(context, WatchdogReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/** Receives the daily watchdog tick. */
class WatchdogReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
AlarmWatchdog.verify(context)
// Re-assert the repeat: setInexactRepeating does not survive a reboot,
// and BootReceiver is the only other thing that would restore it.
AlarmWatchdog.schedule(context)
}
}

View File

@ -14,5 +14,9 @@ class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
AlarmScheduler.rearmFromStorage(context)
// setInexactRepeating does not survive a reboot, so the daily check
// has to be re-established here or it silently stops after the first
// restart — exactly when a watchdog is most needed.
AlarmWatchdog.schedule(context)
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:wake_alarm_sync/screens/home_screen.dart';
import 'package:wake_alarm_sync/screens/ring_screen.dart';
import 'package:wake_alarm_sync/services/alarm_policy.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
@ -18,10 +19,11 @@ final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
/// startup re-arm below runs before the first frame.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// The log store has no change stream, so the picker's save is the only other
// arm hook. Without this, restarting the app without touching the picker
// would leave nothing armed at all.
await AlarmScheduler().rearm();
// The alarm is not opt-in: launching the app is enough to arm it. There is
// no "set alarm" button to forget, and no cancel to reach for at 02:00 —
// AlarmPolicy fixes the time at 08:00 and only ever lets it move earlier.
final policy = await AlarmPolicy.load();
await AlarmScheduler().armSchedule(policy.hour, policy.minute);
runApp(const WakeAlarmSyncApp());
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:wake_alarm_sync/screens/settings_screen.dart';
import 'package:wake_alarm_sync/services/alarm_arming.dart';
import 'package:wake_alarm_sync/services/alarm_policy.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
import 'package:wake_alarm_sync/services/firebase_backend.dart';
@ -21,8 +22,11 @@ class HomeScreen extends StatefulWidget {
}
class _HomeScreenState extends State<HomeScreen> {
int _hour = 7;
int _minute = 0;
int _hour = AlarmPolicy.baselineHour;
int _minute = AlarmPolicy.baselineMinute;
/// True while a one-shot earlier start is in force for the next alarm.
bool _isOverridden = false;
bool _busy = false;
String _status = '';
bool _configured = false;
@ -31,10 +35,17 @@ class _HomeScreenState extends State<HomeScreen> {
/// Platform bridge that arms the app's own alarm (item 1).
final AlarmArming _arming = AlarmArming(AlarmScheduler());
/// Platform bridge used to arm the fixed schedule.
final AlarmScheduler _scheduler = AlarmScheduler();
@override
void initState() {
super.initState();
unawaited(_applyPolicy());
unawaited(_loadSavedTime());
// Publishing to the PC is automatic too: it used to require the same tap
// that armed the alarm, so a forgotten tap left the PC on its fallback.
unawaited(_syncToPc());
}
Future<void> _loadSavedTime() async {
@ -68,21 +79,48 @@ class _HomeScreenState extends State<HomeScreen> {
}
}
Future<void> _pickTime() async {
/// Lets the user move **tomorrow's** alarm earlier, never later.
///
/// This is the only schedule edit the app offers. Earlier-only is what makes
/// it tamper-proof: every change available costs the user sleep, so there is
/// no lever a half-asleep user can pull to stay in bed. A later time, a
/// skipped day and an off switch are simply not expressible.
Future<void> _pickEarlier() async {
final current = _fmt(_hour, _minute);
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: _hour, minute: _minute),
helpText: 'Earlier than $current only',
);
if (picked != null) {
setState(() {
_hour = picked.hour;
_minute = picked.minute;
_status = '';
});
if (picked == null) return;
final accepted = await AlarmPolicy.setEarlier(picked.hour, picked.minute);
if (!mounted) return;
if (!accepted) {
setState(
() => _status =
'That is not earlier than $current. The alarm can only be '
'moved earlier.',
);
return;
}
await _applyPolicy();
if (!mounted) return;
setState(() => _status = 'Armed for ${_fmt(_hour, _minute)} tomorrow.');
}
Future<void> _syncAndSet() async {
/// Re-reads the policy and arms the alarm to match it.
Future<void> _applyPolicy() async {
final policy = await AlarmPolicy.load();
await _scheduler.armSchedule(policy.hour, policy.minute);
if (!mounted) return;
setState(() {
_hour = policy.hour;
_minute = policy.minute;
_isOverridden = policy.isOverridden;
});
}
Future<void> _syncToPc() async {
final settings = await SyncSettings.load();
final service = _service;
// syncBackend() falls back to GitHub alone, or pushes to Firebase alone
@ -116,6 +154,10 @@ class _HomeScreenState extends State<HomeScreen> {
}
}
/// The baseline time, shown when a one-shot override is active.
String get _baseline =>
_fmt(AlarmPolicy.baselineHour, AlarmPolicy.baselineMinute);
String _fmt(int h, int m) =>
'${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
@ -154,38 +196,35 @@ class _HomeScreenState extends State<HomeScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Alarm time', style: TextStyle(fontSize: 16)),
const Text(
'Alarm is armed for',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 16),
GestureDetector(
onTap: _pickTime,
child: Text(
_fmt(_hour, _minute),
style: Theme.of(context).textTheme.displayLarge?.copyWith(
fontWeight: FontWeight.bold,
),
Text(
_fmt(_hour, _minute),
style: Theme.of(context).textTheme.displayLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
TextButton(
onPressed: _pickTime,
child: const Text('Tap to change'),
Text(
_isOverridden
? 'One-shot early start — back to $_baseline '
'after it rings'
: 'Every day. It cannot be turned off.',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _busy ? null : _syncAndSet,
icon: _busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.alarm_add),
label: const Text('Set Alarm & Sync to PC'),
style: ElevatedButton.styleFrom(
child: OutlinedButton.icon(
onPressed: _busy ? null : _pickEarlier,
icon: const Icon(Icons.arrow_upward),
label: const Text('Wake me earlier tomorrow'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 18),
),
),
),

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:wake_alarm_sync/services/alarm_policy.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/challenge.dart';
import 'package:wake_alarm_sync/services/room_proof.dart';
@ -143,6 +144,11 @@ class _RingScreenState extends State<RingScreen> {
Future<void> _finishDismiss() async {
developer.log('dismiss: stopping ring', name: 'WakeAlarmRing');
await _scheduler.dismiss();
// A one-shot earlier start applies to this alarm only. Without the reset
// the first early morning would become permanent, with no way back to the
// 08:00 baseline short of reinstalling — a ratchet with no reset is a
// trap, not a defence.
await AlarmPolicy.resetAfterFire();
developer.log('dismiss: closing screen', name: 'WakeAlarmRing');
await _scheduler.closeRingScreen();
}

View File

@ -0,0 +1,84 @@
import 'package:shared_preferences/shared_preferences.dart';
/// The alarm schedule, which the user cannot weaken.
///
/// The alarm is deliberately **not** opt-in: there is no "set alarm" button to
/// forget and no cancel to reach for at 02:00. The time is baked in, and the
/// only edit the UI offers is moving the *next* alarm **earlier**.
///
/// Earlier-only is what makes this tamper-resistant: every change the user can
/// make costs them sleep, so a half-asleep user has no lever that helps them
/// stay in bed. Moving it later, disabling it, or skipping a day are simply
/// not expressible.
class AlarmPolicy {
/// Creates a policy for a given override, if one is active.
const AlarmPolicy({this.overrideHour, this.overrideMinute});
/// The baseline wake hour. Everything else ratchets down from here.
static const int baselineHour = 8;
/// The baseline wake minute.
static const int baselineMinute = 0;
/// SharedPreferences key for a one-shot earlier override (minutes of day).
static const String kOverrideKey = 'alarmPolicy.overrideMinutes';
/// Hour of the active override, or null when running at the baseline.
final int? overrideHour;
/// Minute of the active override, or null when running at the baseline.
final int? overrideMinute;
/// The hour the next alarm should fire at.
int get hour => overrideHour ?? baselineHour;
/// The minute the next alarm should fire at.
int get minute => overrideMinute ?? baselineMinute;
/// Whether an earlier-than-baseline override is currently in force.
bool get isOverridden => overrideHour != null;
/// Minutes-of-day for the baseline, used to reject non-earlier edits.
static const int _baselineMinutes = baselineHour * 60 + baselineMinute;
/// Loads the policy, applying any active one-shot override.
static Future<AlarmPolicy> load() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getInt(kOverrideKey);
if (stored == null || stored >= _baselineMinutes) {
// A stored value at or after the baseline would be a weakening, so it is
// ignored rather than trusted — the store is not a trust boundary.
return const AlarmPolicy();
}
return AlarmPolicy(
overrideHour: stored ~/ 60,
overrideMinute: stored % 60,
);
}
/// Records a one-shot earlier alarm, returning false if it is not earlier.
///
/// Rejecting a later time is the whole point: this is the only write the UI
/// exposes, so it must be incapable of buying the user extra sleep.
static Future<bool> setEarlier(int hour, int minute) async {
final minutes = hour * 60 + minute;
if (minutes >= _baselineMinutes) return false;
final prefs = await SharedPreferences.getInstance();
final existing = prefs.getInt(kOverrideKey);
// Ratchet: an active override can only be moved earlier still, never
// relaxed back towards the baseline.
if (existing != null && minutes >= existing) return false;
await prefs.setInt(kOverrideKey, minutes);
return true;
}
/// Clears the one-shot override, returning to the baseline.
///
/// Called after the alarm fires. Without this the first early start would
/// become permanent, and the user would have no way back to 08:00 short of
/// reinstalling — a one-way ratchet with no reset is a trap, not a defence.
static Future<void> resetAfterFire() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(kOverrideKey);
}
}

View File

@ -16,18 +16,30 @@ class AlarmScheduler {
/// Arms the alarm for the next occurrence of [hour]:[minute] in local time.
///
/// The phone rings **daily**, matching the PC's `ALARM_DAYS` which is
/// currently all seven days. The two are deliberately not synced — a shared
/// day set is item 8's shape, and diverging defaults would have the halves
/// firing on different days.
/// The phone rings **daily**, matching the PC's `ALARM_DAYS` (all seven
/// days) and its baked-in fallback hour. Callers pass a time resolved by
/// `AlarmPolicy`, never a user-chosen one: the schedule is fixed at 08:00
/// and only ever ratchets earlier.
Future<void> schedule(int hour, int minute) =>
_channel.invokeMethod<void>('schedule', <String, int>{
'hour': hour,
'minute': minute,
});
/// Cancels the armed alarm and forgets the stored time.
Future<void> cancel() => _channel.invokeMethod<void>('cancel');
// There is deliberately NO cancel(). The alarm cannot be switched off from
// the app: the only way past it is the full dismiss ritual at ring time.
// The Kotlin `cancel` handler is gone too, so this is not merely a missing
// Dart wrapper around a live capability.
/// Arms the fixed schedule and starts the daily watchdog.
///
/// Called from the app's init seam, so simply launching the app arms the
/// alarm — there is no button to forget to press.
Future<void> armSchedule(int hour, int minute) =>
_channel.invokeMethod<void>('armSchedule', <String, int>{
'hour': hour,
'minute': minute,
});
/// Re-arms from the stored time; false when no alarm was ever set.
///

View File

@ -0,0 +1,73 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/services/alarm_policy.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
test('a fresh install runs at the baseline', () async {
final policy = await AlarmPolicy.load();
expect(policy.hour, AlarmPolicy.baselineHour);
expect(policy.minute, AlarmPolicy.baselineMinute);
expect(policy.isOverridden, isFalse);
});
test('an earlier time is accepted and applied', () async {
expect(await AlarmPolicy.setEarlier(6, 30), isTrue);
final policy = await AlarmPolicy.load();
expect(policy.hour, 6);
expect(policy.minute, 30);
expect(policy.isOverridden, isTrue);
});
test('a later time is refused', () async {
expect(await AlarmPolicy.setEarlier(9, 0), isFalse);
expect((await AlarmPolicy.load()).hour, AlarmPolicy.baselineHour);
});
test('the baseline itself is not an "earlier" time', () async {
expect(
await AlarmPolicy.setEarlier(
AlarmPolicy.baselineHour,
AlarmPolicy.baselineMinute,
),
isFalse,
);
});
test('an active override can only ratchet further down', () async {
await AlarmPolicy.setEarlier(6, 0);
// 07:00 is earlier than the 08:00 baseline but LATER than the active
// 06:00 override, so accepting it would buy the user an hour of sleep.
expect(await AlarmPolicy.setEarlier(7, 0), isFalse);
expect((await AlarmPolicy.load()).hour, 6);
expect(await AlarmPolicy.setEarlier(5, 30), isTrue);
expect((await AlarmPolicy.load()).hour, 5);
});
test('firing resets to the baseline (one-shot)', () async {
await AlarmPolicy.setEarlier(5, 0);
await AlarmPolicy.resetAfterFire();
final policy = await AlarmPolicy.load();
expect(policy.hour, AlarmPolicy.baselineHour);
expect(policy.isOverridden, isFalse);
});
test('a stored value later than the baseline is ignored', () async {
// The prefs store is not a trust boundary: a value that would weaken the
// alarm must be rejected on read as well as on write.
SharedPreferences.setMockInitialValues(<String, Object>{
AlarmPolicy.kOverrideKey: 23 * 60,
});
final policy = await AlarmPolicy.load();
expect(policy.hour, AlarmPolicy.baselineHour);
expect(policy.isOverridden, isFalse);
});
test('the baseline is 08:00', () async {
expect(AlarmPolicy.baselineHour, 8);
expect(AlarmPolicy.baselineMinute, 0);
});
}

View File

@ -27,9 +27,21 @@ void main() {
expect(calls.single.arguments, <String, int>{'hour': 6, 'minute': 45});
});
test('cancel invokes cancel', () async {
await scheduler.cancel();
expect(calls.single.method, 'cancel');
test('there is no way to cancel the alarm', () {
// Not a missing wrapper: the Kotlin handler is gone too, so the alarm
// genuinely cannot be switched off from the app. The only way past it is
// the full dismiss ritual at ring time.
expect(
calls.where((c) => c.method == 'cancel'),
isEmpty,
reason: 'no code path may invoke cancel',
);
});
test('armSchedule passes the policy time across the channel', () async {
await scheduler.armSchedule(8, 0);
expect(calls.single.method, 'armSchedule');
expect(calls.single.arguments, <String, int>{'hour': 8, 'minute': 0});
});
test('dismiss invokes dismiss', () async {

View File

@ -19,7 +19,12 @@ DISMISS_WINDOW_MINUTES: int = 30
# Hour at which the alarm fires when the phone's alarm cannot be resolved at
# all — no network *and* no cached read ever written. Minute is implicitly 0.
FALLBACK_ALARM_HOUR: int = 13
#
# Deliberately the SAME baseline the phone bakes in (AlarmPolicy.baselineHour),
# so the two halves agree even with the network down. It used to be 13:00,
# which meant a Firebase outage silently moved the alarm five hours later —
# the one direction the whole design refuses to allow.
FALLBACK_ALARM_HOUR: int = 8
# Alarm escalation phase boundaries (minutes from alarm start).
#

View File

@ -29,6 +29,7 @@ import sys
from wake_alarm._alarm_session import should_refire
from wake_alarm._constants import (
ALARM_DAYS,
FALLBACK_ALARM_HOUR,
TRIGGER_CATCHUP_MINUTES,
TRIGGER_TARGET_UNIT,
)
@ -172,13 +173,46 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
return parser.parse_args(argv)
def _clamp_to_baseline(alarm: Alarm) -> Alarm:
"""Refuse to let any resolved alarm land later than the baseline.
The phone bakes in ``FALLBACK_ALARM_HOUR`` and only ever ratchets *earlier*,
so a synced value later than the baseline means something is wrong — a
stale record from before the policy landed, a half-written payload, or a
tampered store. Whatever the cause, the safe direction is unambiguous:
honour it if it is earlier, clamp it if it is later.
This is the PC half of the watchdog. Without it the PC would faithfully
reproduce a bad remote value, and the one thing the design forbids —
the alarm silently moving later — would be reachable through sync.
Args:
alarm: The alarm as resolved by ``get_alarm``.
Returns:
The same alarm, or the baseline when the resolved time is later.
"""
if (alarm.hour, alarm.minute) <= (FALLBACK_ALARM_HOUR, 0):
return alarm
_logger.warning(
"Resolved alarm %02d:%02d is later than the %02d:00 baseline "
"(source=%s) — clamping. The phone can only move it earlier, so a "
"later value means a stale or tampered record.",
alarm.hour,
alarm.minute,
FALLBACK_ALARM_HOUR,
alarm.source,
)
return Alarm(FALLBACK_ALARM_HOUR, 0, f"clamped-{alarm.source}")
def main() -> None:
"""Entry point for one timer tick."""
configure_logging(component="trigger")
args = _parse_args(sys.argv[1:])
now = _now()
alarm = get_alarm()
alarm = _clamp_to_baseline(get_alarm())
should_fire, reason = _decide(now, alarm)
if args.dry_run:

View File

@ -1,107 +1,193 @@
"""Tests for parsing and ranking the alarm payload devices publish.
"""Tests for _alarm_payload.py — parsing and ranking what the phone pushed.
A misparse here does not raise -- it resolves to the wrong wake-up time --
so the malformed shapes are covered as carefully as the good ones.
Split out of test_sync.py: these exercise the wire format, which is a
different module from the fetching/caching this file's siblings cover.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from wake_alarm._alarm_payload import extract_hlc, newest_alarm, parse_log
import pytest
from wake_alarm._alarm_payload import (
extract_field,
extract_hlc,
newest_alarm,
parse_log,
)
if TYPE_CHECKING:
from pathlib import Path
def _log(hour: object = 7, minute: object = 30, hlc: str = "hlc-1") -> str:
def _log(hour: object = 7, minute: object = 30) -> str:
"""Build a crdt_sync log payload shaped like the phone's."""
return json.dumps(
{
"alarm": {
"fields": {
"alarm_hour": [hour, hlc],
"alarm_minute": [minute, hlc],
"alarm_hour": [hour, "hlc-1"],
"alarm_minute": [minute, "hlc-1"],
}
}
}
)
class TestExtractHlc:
def test_reads_the_stamp(self) -> None:
"""The HLC is element 1 of the (value, HLC) pair."""
assert extract_hlc(json.loads(_log(hlc="stamp-9")), "alarm_hour") == "stamp-9"
def test_a_pair_with_no_hlc_reads_as_empty(self) -> None:
"""A one-element pair sorts below every real stamp rather than raising."""
log = {"alarm": {"fields": {"alarm_hour": [7]}}}
assert extract_hlc(log, "alarm_hour") == ""
def test_a_non_string_hlc_reads_as_empty(self) -> None:
"""A numeric stamp is not orderable against real ones; treat as absent."""
log = {"alarm": {"fields": {"alarm_hour": [7, 12345]}}}
assert extract_hlc(log, "alarm_hour") == ""
def test_a_missing_record_reads_as_empty(self) -> None:
"""No alarm record at all is not an error here."""
assert extract_hlc({}, "alarm_hour") == ""
@pytest.fixture
def config_file(tmp_path: Path) -> Path:
"""Provide a temporary sync config path."""
return tmp_path / "sync.json"
class TestNewestAlarm:
def test_no_devices_yields_none(self) -> None:
"""Nothing published is a miss, not a crash."""
assert newest_alarm([]) is None
@pytest.fixture
def cache_file(tmp_path: Path) -> Path:
"""Provide a temporary alarm cache path."""
return tmp_path / "cache" / "alarm_cache.json"
def test_every_device_unusable_yields_none(self) -> None:
"""If no device parses, there is no alarm to return."""
assert newest_alarm([("a", "{not json"), ("b", "[]")]) is None
def test_newest_hlc_wins(self) -> None:
"""The device that set the alarm last wins, not the first listed."""
published = [
("old", _log(9, 0, hlc="2026-07-01T10:00:00.000Z-0000-old")),
("new", _log(5, 30, hlc="2026-08-09T10:00:00.000Z-0000-new")),
]
@pytest.fixture
def fallback_files(tmp_path: Path) -> tuple[Path, Path]:
"""Provide two temporary sibling-app token paths."""
return tmp_path / "diet_guard_token", tmp_path / "screen_locker_token"
assert newest_alarm(published) == (5, 30)
def test_ranking_is_independent_of_listing_order(self) -> None:
"""Reversing the input must not change the winner."""
published = [
("new", _log(5, 30, hlc="2026-08-09T10:00:00.000Z-0000-new")),
("old", _log(9, 0, hlc="2026-07-01T10:00:00.000Z-0000-old")),
]
@pytest.fixture(autouse=True)
def _patch_paths(
config_file: Path, cache_file: Path, fallback_files: tuple[Path, Path]
) -> None:
"""Redirect every on-disk path into tmp_path for every test."""
with (
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
):
yield
assert newest_alarm(published) == (5, 30)
assert newest_alarm(list(reversed(published))) == (5, 30)
def test_an_exact_hlc_tie_breaks_on_device_id(self) -> None:
"""Two devices stamped identically must not alternate between ticks."""
published = [
("aaa", _log(6, 0, hlc="same")),
("zzz", _log(8, 0, hlc="same")),
]
class TestExtractField:
"""Tests for extract_field's tolerance of malformed logs."""
assert newest_alarm(published) == (8, 0)
assert newest_alarm(list(reversed(published))) == (8, 0)
@pytest.mark.parametrize(
"log",
[
{},
{"alarm": "not a dict"},
{"alarm": {}},
{"alarm": {"fields": "not a dict"}},
{"alarm": {"fields": {}}},
{"alarm": {"fields": {"alarm_hour": "not a list"}}},
{"alarm": {"fields": {"alarm_hour": []}}},
{"alarm": {"fields": {"alarm_hour": ["7", "hlc"]}}},
{"alarm": {"fields": {"alarm_hour": [True, "hlc"]}}},
],
)
def test_malformed_shapes_yield_none(self, log: dict[str, Any]) -> None:
"""Every unexpected shape degrades to None rather than raising."""
assert extract_field(log, "alarm_hour") is None
def test_an_unusable_device_is_skipped_not_fatal(self) -> None:
"""A corrupt publisher must not shadow a healthy one."""
published = [("broken", "{not json"), ("good", _log(7, 20))]
assert newest_alarm(published) == (7, 20)
def test_an_out_of_range_device_is_skipped(self) -> None:
"""A nonsense hour never becomes a fire time, even if newest."""
published = [
("bad", _log(99, 0, hlc="2026-08-09T10:00:00.000Z-0000-bad")),
("good", _log(7, 20, hlc="2026-07-01T10:00:00.000Z-0000-good")),
]
assert newest_alarm(published) == (7, 20)
def test_extracts_value(self) -> None:
"""The value is element 0 of the (value, HLC) pair."""
log = {"alarm": {"fields": {"alarm_hour": [6, "hlc"]}}}
assert extract_field(log, "alarm_hour") == 6
class TestParseLog:
def test_a_non_object_payload_yields_none(self) -> None:
"""A JSON array is valid JSON but not a log."""
"""Tests for parse_log."""
def test_valid(self) -> None:
"""A well-formed log parses to an (hour, minute) pair."""
assert parse_log(_log(7, 30)) == (7, 30)
def test_invalid_json(self) -> None:
"""Unparsable JSON yields None."""
assert parse_log("{nope") is None
def test_not_a_dict(self) -> None:
"""A JSON array is not a log."""
assert parse_log("[]") is None
def test_missing_fields(self) -> None:
"""A log without the alarm fields yields None."""
assert parse_log(json.dumps({"alarm": {"fields": {}}})) is None
@pytest.mark.parametrize(("hour", "minute"), [(24, 0), (-1, 0), (7, 60), (7, -1)])
def test_out_of_range(self, hour: int, minute: int) -> None:
"""Values outside 0-23 / 0-59 are rejected."""
assert parse_log(_log(hour, minute)) is None
class TestExtractHlc:
"""The HLC stamp is how the newest device's alarm wins the ranking."""
def test_returns_the_stamp_from_a_well_formed_pair(self) -> None:
"""A `[value, hlc]` pair yields its second element."""
log = {"alarm": {"fields": {"alarm_hour": [7, "2026-08-27T06:00:00Z"]}}}
assert extract_hlc(log, "alarm_hour") == "2026-08-27T06:00:00Z"
def test_a_missing_field_ranks_below_every_real_stamp(self) -> None:
"""Empty string sorts first, so a malformed device never outranks."""
assert extract_hlc({"alarm": {"fields": {}}}, "alarm_hour") == ""
def test_a_short_pair_ranks_below_every_real_stamp(self) -> None:
"""A value with no stamp is unusable for ranking."""
log = {"alarm": {"fields": {"alarm_hour": [7]}}}
assert extract_hlc(log, "alarm_hour") == ""
def test_a_non_string_stamp_is_rejected(self) -> None:
"""A numeric stamp would compare wrongly against real ISO strings."""
log = {"alarm": {"fields": {"alarm_hour": [7, 12345]}}}
assert extract_hlc(log, "alarm_hour") == ""
class TestNewestAlarm:
"""Ranking every device's published alarm, newest HLC wins."""
def test_no_devices_at_all_yields_none(self) -> None:
"""An empty listing must not be mistaken for a valid alarm."""
assert newest_alarm([]) is None
def test_every_device_unusable_yields_none(self) -> None:
"""A device whose payload will not parse is skipped, not trusted."""
assert newest_alarm([("phone", "not json"), ("tablet", "{}")]) is None
def test_the_newest_hlc_wins(self) -> None:
"""The point of the stamp: the most recently set alarm is used."""
older = json.dumps(
{
"alarm": {
"fields": {
"alarm_hour": [6, "2026-08-26T06:00:00Z"],
"alarm_minute": [0, "2026-08-26T06:00:00Z"],
}
}
},
)
newer = json.dumps(
{
"alarm": {
"fields": {
"alarm_hour": [7, "2026-08-27T06:00:00Z"],
"alarm_minute": [30, "2026-08-27T06:00:00Z"],
}
}
},
)
assert newest_alarm([("old", older), ("new", newer)]) == (7, 30)
def test_an_unusable_device_does_not_block_a_good_one(self) -> None:
"""One broken device must not cost the user their alarm."""
good = json.dumps(
{
"alarm": {
"fields": {
"alarm_hour": [5, "2026-08-27T06:00:00Z"],
"alarm_minute": [15, "2026-08-27T06:00:00Z"],
}
}
},
)
assert newest_alarm([("broken", "{}"), ("good", good)]) == (5, 15)

View File

@ -3,23 +3,19 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import urllib.error
import pytest
from wake_alarm._alarm_payload import extract_field, parse_log
from wake_alarm._constants import FALLBACK_ALARM_HOUR
from wake_alarm._sync import (
Alarm,
_fetch_github_alarm,
_parse_args,
_read_cache,
_read_token,
_read_token_fallback,
_write_cache,
get_alarm,
main,
)
if TYPE_CHECKING:
@ -79,133 +75,6 @@ class TestAlarm:
assert str(Alarm(7, 5, "remote")) == "07:05 (remote)"
class TestReadToken:
"""Tests for _read_token."""
def test_missing_file(self) -> None:
"""A missing config file yields no token."""
assert _read_token() is None
def test_malformed_json(self, config_file: Path) -> None:
"""Unparsable JSON yields no token."""
config_file.write_text("{not json")
assert _read_token() is None
def test_not_a_dict(self, config_file: Path) -> None:
"""A JSON array is not a config object."""
config_file.write_text("[]")
assert _read_token() is None
@pytest.mark.parametrize("value", [None, "", 42])
def test_unusable_token_values(self, config_file: Path, value: object) -> None:
"""Absent, empty and non-string tokens are all rejected."""
config_file.write_text(json.dumps({"token": value}))
assert _read_token() is None
def test_valid_token(self, config_file: Path) -> None:
"""A non-empty string token is returned."""
config_file.write_text(json.dumps({"token": "ghp_x"}))
assert _read_token() == "ghp_x"
class TestTokenFallback:
"""Tests for reusing a sibling companion app's token file."""
def test_no_fallback_files(self) -> None:
"""With nothing on disk anywhere there is no token."""
assert _read_token_fallback() is None
assert _read_token() is None
def test_reads_raw_single_line_token(
self, fallback_files: tuple[Path, Path]
) -> None:
"""A sibling token file is a raw PAT, not JSON — and is stripped."""
fallback_files[0].write_text(" ghp_sibling\n")
assert _read_token_fallback() == "ghp_sibling"
def test_skips_empty_file(self, fallback_files: tuple[Path, Path]) -> None:
"""An empty first file falls through to the second."""
fallback_files[0].write_text(" \n")
fallback_files[1].write_text("ghp_second")
assert _read_token_fallback() == "ghp_second"
def test_skips_unreadable_file(self, fallback_files: tuple[Path, Path]) -> None:
"""An unreadable file is skipped rather than fatal."""
fallback_files[1].write_text("ghp_second")
with patch(
"pathlib.Path.read_text",
side_effect=[OSError("denied"), "ghp_second"],
):
assert _read_token_fallback() == "ghp_second"
def test_own_config_wins(
self, config_file: Path, fallback_files: tuple[Path, Path]
) -> None:
"""wake_alarm's own config takes precedence over a sibling's."""
config_file.write_text(json.dumps({"token": "ghp_own"}))
fallback_files[0].write_text("ghp_sibling")
assert _read_token() == "ghp_own"
def test_falls_back_when_config_absent(
self, fallback_files: tuple[Path, Path]
) -> None:
"""With no own config, the sibling token is used."""
fallback_files[0].write_text("ghp_sibling")
assert _read_token() == "ghp_sibling"
class TestExtractField:
"""Tests for extract_field's tolerance of malformed logs."""
@pytest.mark.parametrize(
"log",
[
{},
{"alarm": "not a dict"},
{"alarm": {}},
{"alarm": {"fields": "not a dict"}},
{"alarm": {"fields": {}}},
{"alarm": {"fields": {"alarm_hour": "not a list"}}},
{"alarm": {"fields": {"alarm_hour": []}}},
{"alarm": {"fields": {"alarm_hour": ["7", "hlc"]}}},
{"alarm": {"fields": {"alarm_hour": [True, "hlc"]}}},
],
)
def test_malformed_shapes_yield_none(self, log: dict[str, Any]) -> None:
"""Every unexpected shape degrades to None rather than raising."""
assert extract_field(log, "alarm_hour") is None
def test_extracts_value(self) -> None:
"""The value is element 0 of the (value, HLC) pair."""
log = {"alarm": {"fields": {"alarm_hour": [6, "hlc"]}}}
assert extract_field(log, "alarm_hour") == 6
class TestParseLog:
"""Tests for parse_log."""
def test_valid(self) -> None:
"""A well-formed log parses to an (hour, minute) pair."""
assert parse_log(_log(7, 30)) == (7, 30)
def test_invalid_json(self) -> None:
"""Unparsable JSON yields None."""
assert parse_log("{nope") is None
def test_not_a_dict(self) -> None:
"""A JSON array is not a log."""
assert parse_log("[]") is None
def test_missing_fields(self) -> None:
"""A log without the alarm fields yields None."""
assert parse_log(json.dumps({"alarm": {"fields": {}}})) is None
@pytest.mark.parametrize(("hour", "minute"), [(24, 0), (-1, 0), (7, 60), (7, -1)])
def test_out_of_range(self, hour: int, minute: int) -> None:
"""Values outside 0-23 / 0-59 are rejected."""
assert parse_log(_log(hour, minute)) is None
class TestFetchRemoteAlarm:
"""Tests for the GitHub backend behind fetch_remote_alarm."""
@ -349,38 +218,7 @@ class TestGetAlarm:
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=None):
alarm = get_alarm()
assert (alarm.minute, alarm.source) == (0, "fallback")
assert alarm.hour == 13
class TestCli:
"""Tests for the --show entry point."""
def test_parse_args_defaults(self) -> None:
"""Both flags default to off."""
args = _parse_args([])
assert not args.show
assert not args.no_cache
def test_main_prints_alarm(self, capsys: pytest.CaptureFixture[str]) -> None:
"""main prints the resolved alarm."""
with (
patch("sys.argv", ["_sync", "--show"]),
patch(
"wake_alarm._sync.get_alarm",
return_value=Alarm(7, 30, "remote"),
),
):
main()
assert "07:30 (remote)" in capsys.readouterr().out
def test_no_cache_forces_live_read(self) -> None:
"""--no-cache asks get_alarm for a zero-age cache."""
with (
patch("sys.argv", ["_sync", "--no-cache"]),
patch(
"wake_alarm._sync.get_alarm",
return_value=Alarm(7, 30, "remote"),
) as get,
):
main()
assert get.call_args.kwargs["max_cache_age_seconds"] == 0.0
# Bound to the constant, not a literal: the fallback deliberately
# matches the phone's baked-in baseline, so hardcoding the number here
# would fail every time that shared value moves.
assert alarm.hour == FALLBACK_ALARM_HOUR

View File

@ -0,0 +1,103 @@
"""Tests for `python -m wake_alarm._sync --show`, the documented CLI.
Split out of test_sync.py for the 250-line gate. Kept as its own file
because a CLI extracted to a new module has silently broken before:
it printed nothing and exited 0, invisible to every linter and the suite.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from wake_alarm._sync import (
Alarm,
_parse_args,
main,
)
if TYPE_CHECKING:
from pathlib import Path
def _log(hour: object = 7, minute: object = 30) -> str:
"""Build a crdt_sync log payload shaped like the phone's."""
return json.dumps(
{
"alarm": {
"fields": {
"alarm_hour": [hour, "hlc-1"],
"alarm_minute": [minute, "hlc-1"],
}
}
}
)
@pytest.fixture
def config_file(tmp_path: Path) -> Path:
"""Provide a temporary sync config path."""
return tmp_path / "sync.json"
@pytest.fixture
def cache_file(tmp_path: Path) -> Path:
"""Provide a temporary alarm cache path."""
return tmp_path / "cache" / "alarm_cache.json"
@pytest.fixture
def fallback_files(tmp_path: Path) -> tuple[Path, Path]:
"""Provide two temporary sibling-app token paths."""
return tmp_path / "diet_guard_token", tmp_path / "screen_locker_token"
@pytest.fixture(autouse=True)
def _patch_paths(
config_file: Path, cache_file: Path, fallback_files: tuple[Path, Path]
) -> None:
"""Redirect every on-disk path into tmp_path for every test."""
with (
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
):
yield
class TestCli:
"""Tests for the --show entry point."""
def test_parse_args_defaults(self) -> None:
"""Both flags default to off."""
args = _parse_args([])
assert not args.show
assert not args.no_cache
def test_main_prints_alarm(self, capsys: pytest.CaptureFixture[str]) -> None:
"""main prints the resolved alarm."""
with (
patch("sys.argv", ["_sync", "--show"]),
patch(
"wake_alarm._sync.get_alarm",
return_value=Alarm(7, 30, "remote"),
),
):
main()
assert "07:30 (remote)" in capsys.readouterr().out
def test_no_cache_forces_live_read(self) -> None:
"""--no-cache asks get_alarm for a zero-age cache."""
with (
patch("sys.argv", ["_sync", "--no-cache"]),
patch(
"wake_alarm._sync.get_alarm",
return_value=Alarm(7, 30, "remote"),
) as get,
):
main()
assert get.call_args.kwargs["max_cache_age_seconds"] == 0.0

View File

@ -0,0 +1,140 @@
"""Tests for _sync.py's GitHub token discovery and its sibling fallbacks.
Split out of test_sync.py to hold both files under the 250-line gate.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from wake_alarm._sync import (
_read_token,
_read_token_fallback,
)
if TYPE_CHECKING:
from pathlib import Path
def _log(hour: object = 7, minute: object = 30) -> str:
"""Build a crdt_sync log payload shaped like the phone's."""
return json.dumps(
{
"alarm": {
"fields": {
"alarm_hour": [hour, "hlc-1"],
"alarm_minute": [minute, "hlc-1"],
}
}
}
)
@pytest.fixture
def config_file(tmp_path: Path) -> Path:
"""Provide a temporary sync config path."""
return tmp_path / "sync.json"
@pytest.fixture
def cache_file(tmp_path: Path) -> Path:
"""Provide a temporary alarm cache path."""
return tmp_path / "cache" / "alarm_cache.json"
@pytest.fixture
def fallback_files(tmp_path: Path) -> tuple[Path, Path]:
"""Provide two temporary sibling-app token paths."""
return tmp_path / "diet_guard_token", tmp_path / "screen_locker_token"
@pytest.fixture(autouse=True)
def _patch_paths(
config_file: Path, cache_file: Path, fallback_files: tuple[Path, Path]
) -> None:
"""Redirect every on-disk path into tmp_path for every test."""
with (
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
):
yield
class TestReadToken:
"""Tests for _read_token."""
def test_missing_file(self) -> None:
"""A missing config file yields no token."""
assert _read_token() is None
def test_malformed_json(self, config_file: Path) -> None:
"""Unparsable JSON yields no token."""
config_file.write_text("{not json")
assert _read_token() is None
def test_not_a_dict(self, config_file: Path) -> None:
"""A JSON array is not a config object."""
config_file.write_text("[]")
assert _read_token() is None
@pytest.mark.parametrize("value", [None, "", 42])
def test_unusable_token_values(self, config_file: Path, value: object) -> None:
"""Absent, empty and non-string tokens are all rejected."""
config_file.write_text(json.dumps({"token": value}))
assert _read_token() is None
def test_valid_token(self, config_file: Path) -> None:
"""A non-empty string token is returned."""
config_file.write_text(json.dumps({"token": "ghp_x"}))
assert _read_token() == "ghp_x"
class TestTokenFallback:
"""Tests for reusing a sibling companion app's token file."""
def test_no_fallback_files(self) -> None:
"""With nothing on disk anywhere there is no token."""
assert _read_token_fallback() is None
assert _read_token() is None
def test_reads_raw_single_line_token(
self, fallback_files: tuple[Path, Path]
) -> None:
"""A sibling token file is a raw PAT, not JSON — and is stripped."""
fallback_files[0].write_text(" ghp_sibling\n")
assert _read_token_fallback() == "ghp_sibling"
def test_skips_empty_file(self, fallback_files: tuple[Path, Path]) -> None:
"""An empty first file falls through to the second."""
fallback_files[0].write_text(" \n")
fallback_files[1].write_text("ghp_second")
assert _read_token_fallback() == "ghp_second"
def test_skips_unreadable_file(self, fallback_files: tuple[Path, Path]) -> None:
"""An unreadable file is skipped rather than fatal."""
fallback_files[1].write_text("ghp_second")
with patch(
"pathlib.Path.read_text",
side_effect=[OSError("denied"), "ghp_second"],
):
assert _read_token_fallback() == "ghp_second"
def test_own_config_wins(
self, config_file: Path, fallback_files: tuple[Path, Path]
) -> None:
"""wake_alarm's own config takes precedence over a sibling's."""
config_file.write_text(json.dumps({"token": "ghp_own"}))
fallback_files[0].write_text("ghp_sibling")
assert _read_token() == "ghp_own"
def test_falls_back_when_config_absent(
self, fallback_files: tuple[Path, Path]
) -> None:
"""With no own config, the sibling token is used."""
fallback_files[0].write_text("ghp_sibling")
assert _read_token() == "ghp_sibling"

View File

@ -0,0 +1,66 @@
"""Tests for the PC-side clamp — a synced alarm may never land later."""
from __future__ import annotations
from datetime import datetime
from unittest.mock import patch
import pytest
from wake_alarm._constants import FALLBACK_ALARM_HOUR
from wake_alarm._sync import Alarm
from wake_alarm._trigger import (
_clamp_to_baseline,
)
def _at(hour: int, minute: int) -> datetime:
"""Return a Sunday at the given local wall-clock time, timezone-aware.
Built the same way production does — naive wall clock, then astimezone()
to attach the system offset — so the tests exercise the real comparison
semantics rather than a hand-picked fixed offset.
"""
return datetime(2026, 7, 19, hour, minute, tzinfo=None).astimezone()
@pytest.fixture(autouse=True)
def _ungated() -> None:
"""Open every gate unless a test closes one.
``should_refire`` is stubbed here too: unstubbed it reads the *real*
session record on the host, so a test run started while an alarm was
actually ringing would silently invert half these assertions.
"""
with (
patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False),
patch("wake_alarm._trigger.was_workout_logged_today", return_value=False),
patch("wake_alarm._trigger.should_refire", return_value=False),
):
yield
class TestClampToBaseline:
"""The PC-side watchdog: a synced alarm may never land later than 08:00."""
def test_an_earlier_alarm_passes_through_untouched(self) -> None:
"""The phone ratchets earlier, so earlier values are legitimate."""
alarm = Alarm(6, 30, "remote")
assert _clamp_to_baseline(alarm) == alarm
def test_the_baseline_itself_passes_through(self) -> None:
"""Equal to the baseline is not later than it."""
alarm = Alarm(FALLBACK_ALARM_HOUR, 0, "remote")
assert _clamp_to_baseline(alarm) == alarm
def test_a_later_alarm_is_clamped_to_the_baseline(self) -> None:
"""The one direction the design forbids must not be reachable."""
clamped = _clamp_to_baseline(Alarm(13, 0, "remote"))
assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0)
assert clamped.source == "clamped-remote"
def test_a_later_minute_within_the_baseline_hour_is_clamped(self) -> None:
"""08:30 is later than 08:00, so the minute matters too."""
clamped = _clamp_to_baseline(Alarm(FALLBACK_ALARM_HOUR, 30, "cache"))
assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0)
assert clamped.source == "clamped-cache"