diff --git a/phone_app/android/app/src/main/AndroidManifest.xml b/phone_app/android/app/src/main/AndroidManifest.xml index 3e85628..8bcdb80 100644 --- a/phone_app/android/app/src/main/AndroidManifest.xml +++ b/phone_app/android/app/src/main/AndroidManifest.xml @@ -63,6 +63,11 @@ + + diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt index 60ed8f5..22b4df9 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt @@ -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("hour") + val minute = call.argument("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" -> { diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmWatchdog.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmWatchdog.kt new file mode 100644 index 0000000..22b32c1 --- /dev/null +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmWatchdog.kt @@ -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) + } +} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt index 3799d3a..88aeebc 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/BootReceiver.kt @@ -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) } } diff --git a/phone_app/lib/main.dart b/phone_app/lib/main.dart index d32ae9e..bce9f23 100644 --- a/phone_app/lib/main.dart +++ b/phone_app/lib/main.dart @@ -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 navigatorKey = GlobalKey(); /// startup re-arm below runs before the first frame. Future 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()); } diff --git a/phone_app/lib/screens/home_screen.dart b/phone_app/lib/screens/home_screen.dart index ed5ecec..380aefa 100644 --- a/phone_app/lib/screens/home_screen.dart +++ b/phone_app/lib/screens/home_screen.dart @@ -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 { - 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 { /// 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 _loadSavedTime() async { @@ -68,21 +79,48 @@ class _HomeScreenState extends State { } } - Future _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 _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 _syncAndSet() async { + /// Re-reads the policy and arms the alarm to match it. + Future _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 _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 { } } + /// 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 { 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), ), ), ), diff --git a/phone_app/lib/screens/ring_screen.dart b/phone_app/lib/screens/ring_screen.dart index b787679..28f3454 100644 --- a/phone_app/lib/screens/ring_screen.dart +++ b/phone_app/lib/screens/ring_screen.dart @@ -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 { Future _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(); } diff --git a/phone_app/lib/services/alarm_policy.dart b/phone_app/lib/services/alarm_policy.dart new file mode 100644 index 0000000..0c4aefd --- /dev/null +++ b/phone_app/lib/services/alarm_policy.dart @@ -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 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 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 resetAfterFire() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(kOverrideKey); + } +} diff --git a/phone_app/lib/services/alarm_scheduler.dart b/phone_app/lib/services/alarm_scheduler.dart index 93884e5..d9f2556 100644 --- a/phone_app/lib/services/alarm_scheduler.dart +++ b/phone_app/lib/services/alarm_scheduler.dart @@ -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 schedule(int hour, int minute) => _channel.invokeMethod('schedule', { 'hour': hour, 'minute': minute, }); - /// Cancels the armed alarm and forgets the stored time. - Future cancel() => _channel.invokeMethod('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 armSchedule(int hour, int minute) => + _channel.invokeMethod('armSchedule', { + 'hour': hour, + 'minute': minute, + }); /// Re-arms from the stored time; false when no alarm was ever set. /// diff --git a/phone_app/test/services/alarm_policy_test.dart b/phone_app/test/services/alarm_policy_test.dart new file mode 100644 index 0000000..50358bc --- /dev/null +++ b/phone_app/test/services/alarm_policy_test.dart @@ -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({})); + + 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({ + 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); + }); +} diff --git a/phone_app/test/services/alarm_scheduler_test.dart b/phone_app/test/services/alarm_scheduler_test.dart index fe7956c..e66a24b 100644 --- a/phone_app/test/services/alarm_scheduler_test.dart +++ b/phone_app/test/services/alarm_scheduler_test.dart @@ -27,9 +27,21 @@ void main() { expect(calls.single.arguments, {'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, {'hour': 8, 'minute': 0}); }); test('dismiss invokes dismiss', () async { diff --git a/wake_alarm/_constants.py b/wake_alarm/_constants.py index 6582fc5..41836da 100644 --- a/wake_alarm/_constants.py +++ b/wake_alarm/_constants.py @@ -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). # diff --git a/wake_alarm/_trigger.py b/wake_alarm/_trigger.py index aef6cf6..690ad9f 100644 --- a/wake_alarm/_trigger.py +++ b/wake_alarm/_trigger.py @@ -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: diff --git a/wake_alarm/tests/test_alarm_payload.py b/wake_alarm/tests/test_alarm_payload.py index 08f2d3f..e3e467f 100644 --- a/wake_alarm/tests/test_alarm_payload.py +++ b/wake_alarm/tests/test_alarm_payload.py @@ -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) diff --git a/wake_alarm/tests/test_sync.py b/wake_alarm/tests/test_sync.py index 4e31389..d0ce51a 100644 --- a/wake_alarm/tests/test_sync.py +++ b/wake_alarm/tests/test_sync.py @@ -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 diff --git a/wake_alarm/tests/test_sync_cli.py b/wake_alarm/tests/test_sync_cli.py new file mode 100644 index 0000000..351b9d0 --- /dev/null +++ b/wake_alarm/tests/test_sync_cli.py @@ -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 diff --git a/wake_alarm/tests/test_sync_token.py b/wake_alarm/tests/test_sync_token.py new file mode 100644 index 0000000..a06c136 --- /dev/null +++ b/wake_alarm/tests/test_sync_token.py @@ -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" diff --git a/wake_alarm/tests/test_trigger_clamp.py b/wake_alarm/tests/test_trigger_clamp.py new file mode 100644 index 0000000..2217fd7 --- /dev/null +++ b/wake_alarm/tests/test_trigger_clamp.py @@ -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"