mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:07:03 +01:00
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:
parent
f53003ee4d
commit
95be57b575
@ -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">
|
||||
|
||||
@ -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" -> {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
84
phone_app/lib/services/alarm_policy.dart
Normal file
84
phone_app/lib/services/alarm_policy.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
///
|
||||
|
||||
73
phone_app/test/services/alarm_policy_test.dart
Normal file
73
phone_app/test/services/alarm_policy_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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).
|
||||
#
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
103
wake_alarm/tests/test_sync_cli.py
Normal file
103
wake_alarm/tests/test_sync_cli.py
Normal 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
|
||||
140
wake_alarm/tests/test_sync_token.py
Normal file
140
wake_alarm/tests/test_sync_token.py
Normal 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"
|
||||
66
wake_alarm/tests/test_trigger_clamp.py
Normal file
66
wake_alarm/tests/test_trigger_clamp.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user