mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
Earned ramp: a dismissed Mon/Fri/Sat/Sun morning earns the minute; workdays hold, misses and free days earn nothing; workdays from freedays 0.3.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a4876d84ec
commit
7f7fd916f7
@ -101,7 +101,7 @@ repos:
|
|||||||
# than merely an old one.
|
# than merely an old one.
|
||||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock
|
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock
|
||||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
|
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
|
||||||
# Test suites intentionally use patterns (protected-access, magic-value
|
# Test suites intentionally use patterns (protected-access, magic-value
|
||||||
# comparisons) that don't belong in the source-code 10/10 gate.
|
# comparisons) that don't belong in the source-code 10/10 gate.
|
||||||
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
|
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
|
||||||
|
|||||||
16
AGENTS.md
16
AGENTS.md
@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
**The phone is the alarm.** `phone_app/` arms and rings it and gates the
|
**The phone is the alarm.** `phone_app/` arms and rings it and gates the
|
||||||
dismissal; this repo's Python half only reads the alarm time the phone
|
dismissal; this repo's Python half only reads the alarm time the phone
|
||||||
published and starts the morning routine at that time. The time is a ramp:
|
published and starts the morning routine at that time. The time is an earned
|
||||||
08:00 down a minute a day to 07:00, in three copies — **`DOCS-ramp.md`**.
|
ramp, 08:00 down to 07:00, owned by the phone alone — **`DOCS-ramp.md`**.
|
||||||
|
|
||||||
The PC's waking role was deleted on 2026-08-30: no alarm window, no audio
|
The PC's waking role was deleted on 2026-08-30: no alarm window, no audio
|
||||||
escalation, no smart plug, no curtains, no `rtcwake`, no `shutdown` wrapper,
|
escalation, no smart plug, no curtains, no `rtcwake`, no `shutdown` wrapper,
|
||||||
@ -101,7 +101,7 @@ systemd-sleep hook: a suspended or just-booted machine still fires.
|
|||||||
- **The Firebase reader discovers device directories; it is not pinned to
|
- **The Firebase reader discovers device directories; it is not pinned to
|
||||||
`devices/phone/`.** The phone's device id is migrating from the fixed
|
`devices/phone/`.** The phone's device id is migrating from the fixed
|
||||||
constant `phone` to a per-install uuid, and a pinned reader would silently
|
constant `phone` to a per-install uuid, and a pinned reader would silently
|
||||||
stop seeing the alarm and fall back to the ramp baseline — i.e. the wrong
|
stop seeing the alarm and fall back to 08:00 — i.e. the wrong
|
||||||
time. `_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every
|
time. `_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every
|
||||||
device's `alarm.json`, and picks the newest by the hour field's **HLC**, with
|
device's `alarm.json`, and picks the newest by the hour field's **HLC**, with
|
||||||
`device_id` breaking exact ties. A payload that does not parse is skipped
|
`device_id` breaking exact ties. A payload that does not parse is skipped
|
||||||
@ -127,7 +127,7 @@ systemd-sleep hook: a suspended or just-booted machine still fires.
|
|||||||
for that reason. Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the
|
for that reason. Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the
|
||||||
dev venv.
|
dev venv.
|
||||||
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale
|
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale
|
||||||
cache → today's ramp baseline. The cache bounds remote reads to ~288/day
|
cache → 08:00. The cache bounds remote reads to ~288/day
|
||||||
despite the per-minute tick.
|
despite the per-minute tick.
|
||||||
- **A record older than `PUBLISHED_ALARM_MAX_AGE_SECONDS` (14 days) is skipped
|
- **A record older than `PUBLISHED_ALARM_MAX_AGE_SECONDS` (14 days) is skipped
|
||||||
at every step, including the cache.** The check is on when the *phone
|
at every step, including the cache.** The check is on when the *phone
|
||||||
@ -136,7 +136,7 @@ systemd-sleep hook: a suspended or just-booted machine still fires.
|
|||||||
minutes, so `fetched_at` is always fresh however old the record behind it is.
|
minutes, so `fetched_at` is always fresh however old the record behind it is.
|
||||||
That is why the winning HLC's instant is carried through `_alarm_payload` →
|
That is why the winning HLC's instant is carried through `_alarm_payload` →
|
||||||
`_sync_transport` → the cache's `published_at`. Expiring is safe because
|
`_sync_transport` → the cache's `published_at`. Expiring is safe because
|
||||||
the ramp baseline *is* the phone's baseline. An **undated** record is
|
08:00 is the latest the phone can ever arm. An **undated** record is
|
||||||
trusted, not expired: the GitHub mirror has no per-field HLC, so treating
|
trusted, not expired: the GitHub mirror has no per-field HLC, so treating
|
||||||
"cannot tell" as "too old" would break that backend entirely.
|
"cannot tell" as "too old" would break that backend entirely.
|
||||||
- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute;
|
- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute;
|
||||||
@ -196,9 +196,9 @@ gated on `sudo -n`: the cleanup needs root, and a hard failure there would
|
|||||||
abort an install that had already placed the package and enabled the timer.
|
abort an install that had already placed the package and enabled the timer.
|
||||||
|
|
||||||
**`/etc/sudoers.d/wake-alarm` is deliberately left in place** even by a full
|
**`/etc/sudoers.d/wake-alarm` is deliberately left in place** even by a full
|
||||||
uninstall: screen-locker's own `_wake_alarm.py:_schedule_rtcwake` still shells
|
uninstall. Its last known consumer, screen-locker's `_schedule_rtcwake`, was
|
||||||
out to `sudo rtcwake`, and removing the entry here breaks a live feature in
|
dead code and deleted on 2026-09-27; retiring the entry is a separate,
|
||||||
another repo with no error message anywhere.
|
deliberate decision, never a side effect of an uninstall.
|
||||||
|
|
||||||
## Operational gotchas
|
## Operational gotchas
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ got up at 07:00, the opposite of the point):
|
|||||||
|
|
||||||
| the phone says | `exempt_until` | meaning |
|
| the phone says | `exempt_until` | meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| nothing started today (`none`) | **alarm + 15 min** (`SESSION_NONE_GRACE`; alarm = the phone's, clamped to ≤ today's ramp baseline like the trigger) | up early is fine; the phone rings at the alarm, and a dismiss that has not produced a session by then means "not up" |
|
| nothing started today (`none`) | **alarm + 15 min** (`SESSION_NONE_GRACE`; alarm = the phone's, clamped to ≤ 08:00 like the trigger) | up early is fine; the phone rings at the alarm, and a dismiss that has not produced a session by then means "not up" |
|
||||||
| started, not finished (`in_progress`) | `min(started_at + 90 min, 09:30)` | provisional — the legs take ~40 min; relative to the start because an alarm earlier than 08:00 is legal and a fixed 09:30 would hand an aborted 06:15 session three hours |
|
| started, not finished (`in_progress`) | `min(started_at + 90 min, 09:30)` | provisional — the legs take ~40 min; relative to the start because an alarm earlier than 08:00 is legal and a fixed 09:30 would hand an aborted 06:15 session three hours |
|
||||||
| `completed`, finished inside that same cutoff | **11:00** | the carrot |
|
| `completed`, finished inside that same cutoff | **11:00** | the carrot |
|
||||||
| `completed` *after* the cutoff | none | a phone switched back on at noon completes the legs then; that un-locks nothing |
|
| `completed` *after* the cutoff | none | a phone switched back on at noon completes the legs then; that un-locks nothing |
|
||||||
|
|||||||
51
DOCS-ramp.md
51
DOCS-ramp.md
@ -1,20 +1,39 @@
|
|||||||
# The wake-time ramp ("cooking the frog")
|
# The wake-time ramp ("cooking the frog")
|
||||||
|
|
||||||
|
The alarm starts at 08:00 and moves one **earned** minute earlier at a time,
|
||||||
|
down to a permanent 07:00. Owned entirely by the phone
|
||||||
|
(`phone_app/lib/services/alarm_ramp.dart`, `ramp_progress.dart`).
|
||||||
|
|
||||||
The baseline is not a constant: 08:00 through 2026-09-25, then one minute
|
## The rule
|
||||||
earlier per **calendar** day (free days count) to 07:00 on 2026-11-24, which
|
|
||||||
is permanent. A pure function of the date the alarm **fires** on — no counter,
|
|
||||||
no stored start — so a reinstall cannot reset it. Three copies must agree:
|
|
||||||
`wake_alarm/_ramp.py` (PC fallback + clamp), `phone_app/lib/services/alarm_ramp.dart`
|
|
||||||
(`AlarmPolicy`) and `AlarmRamp.kt` (every native arm path funnels through
|
|
||||||
`AlarmScheduler.nextOccurrence`, so the dismiss/boot/free-day/watchdog re-arms
|
|
||||||
keep stepping without Dart). `tests/test_ramp.py` parses the other two.
|
|
||||||
|
|
||||||
- **Evaluate the fire day, never the arm day.** The phone arms the evening
|
- A **Monday, Friday, Saturday or Sunday** whose alarm rang and was dismissed
|
||||||
before; "today's" ramp there is tomorrow's time plus a minute, every day.
|
that same day earns one minute (`RampProgress.recordDismiss`, called from the
|
||||||
- The PC clamps any synced time to *today's* ramp. A record up to 08:00 is
|
ring screen's dismiss before it re-arms).
|
||||||
expected lag (the phone publishes when the app runs) and logs INFO; only a
|
- **Tuesday–Thursday** (`freedays.WORKDAYS`) earn nothing and ring at the time
|
||||||
time later than 08:00 is a WARNING.
|
of the most recent non-workday: a minute earned on Monday first shows on
|
||||||
- The one-shot earlier override lapses once the ramp catches up with it, and
|
Friday.
|
||||||
the ring screen re-arms the policy after `resetAfterFire` — `dismiss()`
|
- A **free day** never rings, so it earns nothing. A **missed** alarm (rang,
|
||||||
natively re-arms the *stored* time, i.e. the override just spent.
|
never dismissed) earns nothing.
|
||||||
|
- Ring time for day D = 08:00 − |earned dates before the latest non-workday
|
||||||
|
≤ D|, floored at 07:00.
|
||||||
|
|
||||||
|
## Why a set of dates, not a counter
|
||||||
|
|
||||||
|
The earned dates are a grow-only set: each date counts once, so a re-run, a
|
||||||
|
duplicate sync or a double dismiss cannot move the alarm twice, and nothing can
|
||||||
|
run it away. It rides the synced `alarm.json` record (`settings_sync.dart`,
|
||||||
|
field `ramp_earned_dates`) as a union, so a reinstall gets every minute back.
|
||||||
|
`rampSeedDates` holds the three mornings earned before dates were recorded
|
||||||
|
(2026-09-25/26/27).
|
||||||
|
|
||||||
|
## Where it is *not*
|
||||||
|
|
||||||
|
The PC no longer computes a ramp: it trusts the time the phone publishes,
|
||||||
|
clamped to ≤ 08:00, and falls back to 08:00. The Kotlin side has no ramp
|
||||||
|
either: the alarm only moves when a Dart dismiss earns a minute and re-arms, so
|
||||||
|
the native re-arm after a free day, a missed ring, a boot or the watchdog is
|
||||||
|
already the right time by re-using the stored one.
|
||||||
|
|
||||||
|
`workdays` in `alarm_ramp.dart` and `STICK_DAYS` in `WorkdayStick.kt` are
|
||||||
|
literal copies of `freedays.WORKDAYS`;
|
||||||
|
`wake_alarm/tests/test_workday_mirrors.py` fails when either drifts.
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
package com.kuhy.wake_alarm_sync
|
|
||||||
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZonedDateTime
|
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The baseline wake time, lowered one minute per calendar day to 07:00.
|
|
||||||
*
|
|
||||||
* Native copy of `lib/services/alarm_ramp.dart`: the daily re-arm after a
|
|
||||||
* dismiss, a free day, a boot or the watchdog runs here with no Dart at all,
|
|
||||||
* so without this copy it would re-arm the stored time and the ramp would
|
|
||||||
* stall until the app was next opened. `wake_alarm/tests/test_ramp.py` parses
|
|
||||||
* these constants and fails when they drift from the Dart and Python copies.
|
|
||||||
*/
|
|
||||||
object AlarmRamp {
|
|
||||||
|
|
||||||
/** The last day at the old 08:00 baseline; each day after is a minute earlier. */
|
|
||||||
val ANCHOR: LocalDate = LocalDate.of(2026, 9, 25)
|
|
||||||
|
|
||||||
/** Minutes of day on the anchor date (08:00). */
|
|
||||||
const val START_MINUTES = 8 * 60
|
|
||||||
|
|
||||||
/** The floor the ramp stops at, for good (07:00). */
|
|
||||||
const val FLOOR_MINUTES = 7 * 60
|
|
||||||
|
|
||||||
/** Baseline on [day], in minutes of day. Calendar days, so DST cannot skew it. */
|
|
||||||
fun minutesOn(day: LocalDate): Int {
|
|
||||||
val elapsed = maxOf(0L, ChronoUnit.DAYS.between(ANCHOR, day))
|
|
||||||
return maxOf(FLOOR_MINUTES.toLong(), START_MINUTES - elapsed).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Next fire instant after [now] for a requested time of [requested] minutes.
|
|
||||||
*
|
|
||||||
* The ramp is evaluated for the day the alarm fires on, and wins whenever
|
|
||||||
* it is earlier than the request; tomorrow always qualifies because the
|
|
||||||
* ramp never moves later.
|
|
||||||
*/
|
|
||||||
fun nextFire(requested: Int, now: ZonedDateTime): ZonedDateTime {
|
|
||||||
val today = now.toLocalDate()
|
|
||||||
val first = at(today, requested, now)
|
|
||||||
return if (first.isAfter(now)) first else at(today.plusDays(1), requested, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun at(day: LocalDate, requested: Int, now: ZonedDateTime): ZonedDateTime {
|
|
||||||
val minutes = minOf(requested, minutesOn(day))
|
|
||||||
return day.atTime(minutes / 60, minutes % 60).atZone(now.zone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,7 +6,7 @@ import android.app.PendingIntent
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import java.time.ZonedDateTime
|
import java.util.Calendar
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arms and cancels the one exact alarm this app owns.
|
* Arms and cancels the one exact alarm this app owns.
|
||||||
@ -162,15 +162,18 @@ object AlarmScheduler {
|
|||||||
.putInt(KEY_HOUR, hour).putInt(KEY_MINUTE, minute).apply()
|
.putInt(KEY_HOUR, hour).putInt(KEY_MINUTE, minute).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Next local-time instant matching [hour]:[minute]; tomorrow if today has passed. */
|
||||||
* Next local-time instant for [hour]:[minute], never later than that day's ramp.
|
private fun nextOccurrence(hour: Int, minute: Int): Long {
|
||||||
*
|
val now = Calendar.getInstance()
|
||||||
* Every arm path funnels through here, so the stored time is only an upper
|
val at = Calendar.getInstance().apply {
|
||||||
* bound: the dismiss/boot/free-day re-arms still move a minute earlier a day.
|
set(Calendar.HOUR_OF_DAY, hour)
|
||||||
*/
|
set(Calendar.MINUTE, minute)
|
||||||
private fun nextOccurrence(hour: Int, minute: Int): Long =
|
set(Calendar.SECOND, 0)
|
||||||
AlarmRamp.nextFire(hour * 60 + minute, ZonedDateTime.now())
|
set(Calendar.MILLISECOND, 0)
|
||||||
.toInstant().toEpochMilli()
|
}
|
||||||
|
if (!at.after(now)) at.add(Calendar.DAY_OF_YEAR, 1)
|
||||||
|
return at.timeInMillis
|
||||||
|
}
|
||||||
|
|
||||||
/** FLAG_IMMUTABLE is mandatory from API 31; without it this throws at arm time. */
|
/** FLAG_IMMUTABLE is mandatory from API 31; without it this throws at arm time. */
|
||||||
private fun firePendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
|
private fun firePendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
|||||||
import 'package:wake_alarm_sync/services/challenge.dart';
|
import 'package:wake_alarm_sync/services/challenge.dart';
|
||||||
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
|
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
|
||||||
import 'package:wake_alarm_sync/services/home_presence.dart';
|
import 'package:wake_alarm_sync/services/home_presence.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||||
import 'package:wake_alarm_sync/services/ring_startup.dart';
|
import 'package:wake_alarm_sync/services/ring_startup.dart';
|
||||||
import 'package:wake_alarm_sync/services/room_proof.dart';
|
import 'package:wake_alarm_sync/services/room_proof.dart';
|
||||||
import 'package:wake_alarm_sync/services/session_handoff.dart';
|
import 'package:wake_alarm_sync/services/session_handoff.dart';
|
||||||
@ -217,6 +218,9 @@ class _RingScreenState extends State<RingScreen> {
|
|||||||
// ramp baseline short of reinstalling — a ratchet with no reset is a
|
// ramp baseline short of reinstalling — a ratchet with no reset is a
|
||||||
// trap, not a defence.
|
// trap, not a defence.
|
||||||
await AlarmPolicy.resetAfterFire();
|
await AlarmPolicy.resetAfterFire();
|
||||||
|
// A dismissed non-workday morning earns the ramp a minute; recorded
|
||||||
|
// before the re-arm below so it is what the next non-workday uses.
|
||||||
|
await RampProgress.recordDismiss(DateTime.now());
|
||||||
// dismiss() re-armed the *stored* time, i.e. the override just spent;
|
// dismiss() re-armed the *stored* time, i.e. the override just spent;
|
||||||
// without re-arming the policy it rang again every day until home opened.
|
// without re-arming the policy it rang again every day until home opened.
|
||||||
final next = await AlarmPolicy.load();
|
final next = await AlarmPolicy.load();
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_ramp.dart';
|
import 'package:wake_alarm_sync/services/alarm_ramp.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||||
|
|
||||||
/// The alarm schedule, which the user cannot weaken.
|
/// The alarm schedule, which the user cannot weaken.
|
||||||
///
|
///
|
||||||
/// The alarm is deliberately **not** opt-in: there is no "set alarm" button to
|
/// 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 — the
|
/// forget and no cancel to reach for at 02:00. The time is baked in — the
|
||||||
/// daily ramp in `alarm_ramp.dart`, 08:00 down to 07:00 a minute a day — and
|
/// earned ramp in `alarm_ramp.dart`, 08:00 down to 07:00 a minute per
|
||||||
/// the only edit the UI offers is moving the *next* alarm **earlier**.
|
/// dismissed non-workday morning — 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
|
/// 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
|
/// make costs them sleep, so a half-asleep user has no lever that helps them
|
||||||
@ -21,16 +23,21 @@ class AlarmPolicy {
|
|||||||
this.overrideMinute,
|
this.overrideMinute,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Resolves the next alarm after [now], given a [stored] one-shot override.
|
/// Resolves the next alarm after [now], given a [stored] one-shot override
|
||||||
|
/// and the [earned] ramp dates (`RampProgress`).
|
||||||
///
|
///
|
||||||
/// The ramp is evaluated for the day the alarm will actually **fire** on,
|
/// The ramp is evaluated for the day the alarm will actually **fire** on,
|
||||||
/// not the day it is armed: `main()` and the home screen usually arm the
|
/// not the day it is armed: `main()` and the home screen usually arm the
|
||||||
/// evening before, and evaluating "today" there would arm tomorrow at
|
/// evening before, and evaluating "today" there would arm tomorrow at
|
||||||
/// today's (one minute later) time, every single day. Today is tried
|
/// today's (one minute later) time, every single day. Today is tried
|
||||||
/// first; tomorrow always qualifies because the ramp never moves later.
|
/// first; tomorrow always qualifies because the ramp never moves later.
|
||||||
factory AlarmPolicy.resolve(DateTime now, int? stored) {
|
factory AlarmPolicy.resolve(
|
||||||
|
DateTime now,
|
||||||
|
int? stored, [
|
||||||
|
Set<String> earned = const <String>{},
|
||||||
|
]) {
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
var policy = AlarmPolicy._forDay(today, stored);
|
var policy = AlarmPolicy._forDay(today, stored, earned);
|
||||||
// Built from fields, not today + Duration: on a DST day local midnight
|
// Built from fields, not today + Duration: on a DST day local midnight
|
||||||
// plus N minutes is not N minutes past midnight on the wall clock.
|
// plus N minutes is not N minutes past midnight on the wall clock.
|
||||||
final at = DateTime(
|
final at = DateTime(
|
||||||
@ -44,13 +51,14 @@ class AlarmPolicy {
|
|||||||
policy = AlarmPolicy._forDay(
|
policy = AlarmPolicy._forDay(
|
||||||
DateTime(now.year, now.month, now.day + 1),
|
DateTime(now.year, now.month, now.day + 1),
|
||||||
stored,
|
stored,
|
||||||
|
earned,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return policy;
|
return policy;
|
||||||
}
|
}
|
||||||
|
|
||||||
factory AlarmPolicy._forDay(DateTime day, int? stored) {
|
factory AlarmPolicy._forDay(DateTime day, int? stored, Set<String> earned) {
|
||||||
final base = rampMinutesOn(day);
|
final base = rampMinutesOn(day, earned);
|
||||||
// A stored value at or after the baseline would be a weakening, so it is
|
// 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.
|
// ignored rather than trusted — the store is not a trust boundary.
|
||||||
final override = stored != null && stored < base ? stored : null;
|
final override = stored != null && stored < base ? stored : null;
|
||||||
@ -92,6 +100,7 @@ class AlarmPolicy {
|
|||||||
return AlarmPolicy.resolve(
|
return AlarmPolicy.resolve(
|
||||||
now ?? DateTime.now(),
|
now ?? DateTime.now(),
|
||||||
prefs.getInt(kOverrideKey),
|
prefs.getInt(kOverrideKey),
|
||||||
|
await RampProgress.load(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,36 +1,60 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
// The baseline wake time, lowered one minute per calendar day to 07:00.
|
// The baseline wake time, lowered one *earned* minute at a time to 07:00.
|
||||||
//
|
//
|
||||||
// "Cooking the frog": 2026-09-25 is the last 08:00 morning, 2026-09-26 rings
|
// "Cooking the frog": a Monday/Friday/Saturday/Sunday morning whose alarm
|
||||||
// at 07:59, and so on until 2026-11-24 reaches the 07:00 floor, which is then
|
// rang and was dismissed that same day earns one minute, and the alarm moves
|
||||||
// permanent. The time is a pure function of the date the alarm *fires* on —
|
// a minute earlier from the next non-workday on. Workdays (Tue/Wed/Thu) earn
|
||||||
// no counter, no stored start date — so neither a reinstall nor wiped app
|
// nothing and hold the latest earned time; a free day never rings so it earns
|
||||||
// data can reset the ramp. Every calendar day counts, free days included, so
|
// nothing; a missed alarm earns nothing. 07:00 is the floor, for good.
|
||||||
// free days cannot stall it.
|
|
||||||
//
|
//
|
||||||
// Mirrored by `AlarmRamp.kt` (native re-arm) and `wake_alarm/_ramp.py` (PC
|
// The earned dates are a grow-only set (`RampProgress`, synced so a reinstall
|
||||||
// fallback and clamp); `wake_alarm/tests/test_ramp.py` parses both copies
|
// gets them back): each date counts once, so no bug can run it away, and the
|
||||||
// and fails when the constants drift.
|
// time is a pure function of the set and the day the alarm fires on.
|
||||||
|
|
||||||
/// The last day at the old 08:00 baseline; each day after it is a minute
|
/// Mirror of `freedays.WORKDAYS` (Tue/Wed/Thu), which Dart cannot import;
|
||||||
/// earlier.
|
/// `wake_alarm/tests/test_workday_mirrors.py` fails when it drifts.
|
||||||
final DateTime rampAnchor = DateTime.utc(2026, 9, 25);
|
const Set<int> workdays = {
|
||||||
|
DateTime.tuesday,
|
||||||
|
DateTime.wednesday,
|
||||||
|
DateTime.thursday,
|
||||||
|
};
|
||||||
|
|
||||||
/// Minutes of day on the anchor date (08:00).
|
/// Minutes of day before anything was earned (08:00).
|
||||||
const int rampStartMinutes = 8 * 60;
|
const int rampStartMinutes = 8 * 60;
|
||||||
|
|
||||||
/// The floor the ramp stops at, for good (07:00).
|
/// The floor the ramp stops at, for good (07:00).
|
||||||
const int rampFloorMinutes = 7 * 60;
|
const int rampFloorMinutes = 7 * 60;
|
||||||
|
|
||||||
/// Baseline wake time on [day]'s local calendar date, in minutes of day.
|
/// Minutes earned before earned dates were recorded: the three dismissed
|
||||||
|
/// mornings that had already taken the alarm to 07:59 (Sat), 07:58 (Sun) and
|
||||||
|
/// 07:57 (Mon) when the ramp stopped being a pure calendar count, 2026-09-27.
|
||||||
|
const Set<String> rampSeedDates = {'2026-09-25', '2026-09-26', '2026-09-27'};
|
||||||
|
|
||||||
|
/// Whether [day] is a weekly workday, on which the ramp holds.
|
||||||
|
bool isWorkday(DateTime day) => workdays.contains(day.weekday);
|
||||||
|
|
||||||
|
/// `YYYY-MM-DD` of [day]'s local calendar date.
|
||||||
|
String rampDateKey(DateTime day) =>
|
||||||
|
'${day.year.toString().padLeft(4, '0')}-'
|
||||||
|
'${day.month.toString().padLeft(2, '0')}-'
|
||||||
|
'${day.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
/// Baseline wake time for an alarm firing on [day], in minutes of day.
|
||||||
///
|
///
|
||||||
/// Only the date of [day] matters. Days are counted between UTC midnights
|
/// Counts the earned non-workday dates strictly before the most recent
|
||||||
/// built from the local y/m/d, so a DST change (2026-10-25 is inside the
|
/// non-workday on or before [day], so a minute earned on Monday first shows on
|
||||||
/// ramp) cannot turn a 23- or 25-hour day into a skipped or doubled minute.
|
/// Friday and Tuesday-Thursday ring at Monday's time. Date keys compare as
|
||||||
/// Days before the anchor stay at 08:00 rather than extrapolating later.
|
/// strings, so DST cannot skew the count.
|
||||||
int rampMinutesOn(DateTime day) {
|
int rampMinutesOn(DateTime day, Set<String> earned) {
|
||||||
final date = DateTime.utc(day.year, day.month, day.day);
|
var step = DateTime(day.year, day.month, day.day);
|
||||||
final elapsed = math.max(0, date.difference(rampAnchor).inDays);
|
while (isWorkday(step)) {
|
||||||
return math.max(rampFloorMinutes, rampStartMinutes - elapsed);
|
step = DateTime(step.year, step.month, step.day - 1);
|
||||||
|
}
|
||||||
|
final cutoff = rampDateKey(step);
|
||||||
|
final counted = {...earned, ...rampSeedDates}.where((key) {
|
||||||
|
final date = DateTime.tryParse(key);
|
||||||
|
return date != null && key.compareTo(cutoff) < 0 && !isWorkday(date);
|
||||||
|
});
|
||||||
|
return math.max(rampFloorMinutes, rampStartMinutes - counted.length);
|
||||||
}
|
}
|
||||||
|
|||||||
57
phone_app/lib/services/ramp_progress.dart
Normal file
57
phone_app/lib/services/ramp_progress.dart
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/alarm_ramp.dart';
|
||||||
|
|
||||||
|
/// The dates whose dismissed alarm earned the ramp a minute.
|
||||||
|
///
|
||||||
|
/// A grow-only set: a date is added once and never removed, so recording is
|
||||||
|
/// idempotent and the ramp can move at most one minute per earning day --
|
||||||
|
/// the property a bare counter lacks (a re-run or a duplicate sync would
|
||||||
|
/// double-count it). Synced as a union (`settings_sync.dart`), so a reinstall
|
||||||
|
/// or a second install gets every earned minute back.
|
||||||
|
class RampProgress {
|
||||||
|
const RampProgress._();
|
||||||
|
|
||||||
|
/// SharedPreferences key holding the earned dates as a JSON list.
|
||||||
|
static const String kEarnedKey = 'ramp.earnedDates';
|
||||||
|
|
||||||
|
/// The earned date keys (`YYYY-MM-DD`); unreadable storage reads as none.
|
||||||
|
static Future<Set<String>> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(kEarnedKey);
|
||||||
|
if (raw == null) return <String>{};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is List) return decoded.whereType<String>().toSet();
|
||||||
|
} on FormatException {
|
||||||
|
// A corrupt value earns nothing rather than crashing the arm path.
|
||||||
|
}
|
||||||
|
return <String>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that the alarm firing on [now]'s date was dismissed.
|
||||||
|
///
|
||||||
|
/// Only a non-workday earns: on Tuesday-Thursday the ramp holds. Returns
|
||||||
|
/// whether a new date was added.
|
||||||
|
static Future<bool> recordDismiss(DateTime now) async {
|
||||||
|
if (isWorkday(now)) return false;
|
||||||
|
return await merge(<String>{rampDateKey(now)});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unions [dates] into the stored set; returns whether it grew.
|
||||||
|
static Future<bool> merge(Iterable<String> dates) async {
|
||||||
|
final current = await load();
|
||||||
|
final merged = {...current, ...dates.where(_isDateKey)};
|
||||||
|
if (merged.length == current.length) return false;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(kEarnedKey, jsonEncode(merged.toList()..sort()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only canonical `YYYY-MM-DD` keys: the ramp compares them as strings.
|
||||||
|
static bool _isDateKey(String key) {
|
||||||
|
final date = DateTime.tryParse(key);
|
||||||
|
return date != null && rampDateKey(date) == key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
/// The NFC tags and home location, carried on the synced alarm record.
|
/// The NFC tags, home location and ramp progress, on the synced alarm record.
|
||||||
///
|
///
|
||||||
/// Both used to live only in SharedPreferences, so a reinstall lost them and
|
/// Both used to live only in SharedPreferences, so a reinstall lost them and
|
||||||
/// the user re-registered two tags and re-captured home by hand (2026-09-11).
|
/// the user re-registered two tags and re-captured home by hand (2026-09-11).
|
||||||
@ -20,12 +20,16 @@ library;
|
|||||||
import 'package:crdt_sync/crdt_sync.dart';
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||||
import 'package:wake_alarm_sync/services/home_location_settings.dart';
|
import 'package:wake_alarm_sync/services/home_location_settings.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||||
import 'package:wake_alarm_sync/services/session_sync.dart';
|
import 'package:wake_alarm_sync/services/session_sync.dart';
|
||||||
|
|
||||||
/// Field holding the registered tag ids, one string per slot.
|
/// Field holding the registered tag ids, one string per slot.
|
||||||
const nfcTagsField = 'nfc_tag_ids';
|
const nfcTagsField = 'nfc_tag_ids';
|
||||||
|
|
||||||
|
/// Field holding the ramp's earned dates ([RampProgress]), merged as a union.
|
||||||
|
const rampEarnedField = 'ramp_earned_dates';
|
||||||
|
|
||||||
/// Field holding [HomeLocationSettings.toJson]; a cleared home is the
|
/// Field holding [HomeLocationSettings.toJson]; a cleared home is the
|
||||||
/// all-null shape, not an absent field, so clearing propagates too.
|
/// all-null shape, not an absent field, so clearing propagates too.
|
||||||
const homeLocationField = 'home_location';
|
const homeLocationField = 'home_location';
|
||||||
@ -65,9 +69,27 @@ Future<void> stageLocalSettings(AlarmSyncService service) async {
|
|||||||
home.toJson(),
|
home.toJson(),
|
||||||
configured: home.isConfigured,
|
configured: home.isConfigured,
|
||||||
);
|
);
|
||||||
|
await _stageEarned(service);
|
||||||
await stageSessionData(service);
|
await stageSessionData(service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pushes the union of the local and logged earned dates when it is new.
|
||||||
|
///
|
||||||
|
/// A grow-only set rides a last-writer-wins field safely: every writer pushes
|
||||||
|
/// a superset of what it last saw, and a device whose dates lost a race still
|
||||||
|
/// holds them locally and re-pushes the union on its next sync.
|
||||||
|
Future<void> _stageEarned(AlarmSyncService service) async {
|
||||||
|
final local = await RampProgress.load();
|
||||||
|
final logged = _datesOf(service.field(rampEarnedField)?.$1);
|
||||||
|
if (local.difference(logged).isEmpty) return;
|
||||||
|
await service.setFields({
|
||||||
|
rampEarnedField: ({...logged, ...local}.toList()..sort()),
|
||||||
|
}, Hlc.newTick(service.deviceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _datesOf(Object? value) =>
|
||||||
|
value is List ? value.whereType<String>().toSet() : <String>{};
|
||||||
|
|
||||||
/// The stamp of a pre-stamp value: just above the epoch, so any real edit
|
/// The stamp of a pre-stamp value: just above the epoch, so any real edit
|
||||||
/// wins, but present, so it is pushed rather than silently dropped.
|
/// wins, but present, so it is pushed rather than silently dropped.
|
||||||
const _legacyMs = 1;
|
const _legacyMs = 1;
|
||||||
@ -118,6 +140,7 @@ Future<void> applySyncedSettings(AlarmSyncService service) async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await RampProgress.merge(_datesOf(service.field(rampEarnedField)?.$1));
|
||||||
await applySyncedSessionData(service);
|
await applySyncedSessionData(service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,11 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'package:wake_alarm_sync/screens/ring_screen.dart';
|
import 'package:wake_alarm_sync/screens/ring_screen.dart';
|
||||||
import 'package:wake_alarm_sync/screens/session_screen.dart';
|
import 'package:wake_alarm_sync/screens/session_screen.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_policy.dart';
|
import 'package:wake_alarm_sync/services/alarm_policy.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/alarm_ramp.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||||
import 'package:wake_alarm_sync/services/challenge.dart';
|
import 'package:wake_alarm_sync/services/challenge.dart';
|
||||||
import 'package:wake_alarm_sync/services/home_location_settings.dart';
|
import 'package:wake_alarm_sync/services/home_location_settings.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||||
import 'package:wake_alarm_sync/services/session_store.dart';
|
import 'package:wake_alarm_sync/services/session_store.dart';
|
||||||
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
|
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
|
||||||
@ -120,6 +122,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(armed.last, <String, int>{'hour': next.hour, 'minute': next.minute});
|
expect(armed.last, <String, int>{'hour': next.hour, 'minute': next.minute});
|
||||||
expect(next.isOverridden, isFalse);
|
expect(next.isOverridden, isFalse);
|
||||||
|
// The dismiss earned today's minute -- unless today is a workday.
|
||||||
|
final now = DateTime.now();
|
||||||
|
expect(
|
||||||
|
(await RampProgress.load()).contains(rampDateKey(now)),
|
||||||
|
!isWorkday(now),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('the session hears tags after the ring screen is gone', (
|
testWidgets('the session hears tags after the ring screen is gone', (
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_policy.dart';
|
import 'package:wake_alarm_sync/services/alarm_policy.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/alarm_ramp.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||||
|
|
||||||
/// Every test pins "now": the baseline is the ramp, so an unpinned test that
|
/// Every test pins "now": the baseline is the ramp, so an unpinned test that
|
||||||
/// passes at 08:00 today fails at 07:59 tomorrow.
|
/// passes at 08:00 today fails at 07:59 tomorrow.
|
||||||
@ -27,16 +29,32 @@ void main() {
|
|||||||
|
|
||||||
test('before the alarm on its own day, it is still today', () {
|
test('before the alarm on its own day, it is still today', () {
|
||||||
final policy = AlarmPolicy.resolve(DateTime(2026, 10, 5, 3), null);
|
final policy = AlarmPolicy.resolve(DateTime(2026, 10, 5, 3), null);
|
||||||
expect(hm(policy), '07:50');
|
expect(hm(policy), '07:57');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('once today has fired, it is tomorrow', () {
|
test('once today has fired, it is tomorrow, with today earned', () {
|
||||||
final policy = AlarmPolicy.resolve(DateTime(2026, 10, 5, 7, 50), null);
|
// Saturday 07:57 has rung and been dismissed; Sunday counts it.
|
||||||
expect(hm(policy), '07:49');
|
final policy = AlarmPolicy.resolve(DateTime(2026, 10, 3, 7, 57), null, {
|
||||||
|
'2026-10-03',
|
||||||
|
});
|
||||||
|
expect(hm(policy), '07:56');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('past the floor it stays at 07:00', () {
|
test('past the floor it stays at 07:00', () {
|
||||||
expect(hm(AlarmPolicy.resolve(DateTime(2027, 3, 1, 12), null)), '07:00');
|
final earned = {
|
||||||
|
for (var i = 0; i < 200; i++) rampDateKey(DateTime(2026, 10, 2 + i)),
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
hm(AlarmPolicy.resolve(DateTime(2027, 12, 1, 12), null, earned)),
|
||||||
|
'07:00',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('load reads the earned dates from storage', () async {
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||||
|
RampProgress.kEarnedKey: '["2026-10-03"]',
|
||||||
|
});
|
||||||
|
expect(hm(await AlarmPolicy.load(now: DateTime(2026, 10, 3, 22))), '07:56');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an earlier time is accepted and applied', () async {
|
test('an earlier time is accepted and applied', () async {
|
||||||
@ -89,10 +107,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('an override the ramp has caught up with lapses', () {
|
test('an override the ramp has caught up with lapses', () {
|
||||||
// 07:45 was an early start in September; by 2026-10-20 the ramp itself
|
// 07:58 was an early start on Saturday; Monday's ramp is already 07:57,
|
||||||
// is 07:35, so the stale override would be a *later* alarm.
|
// so the stale override would be a *later* alarm.
|
||||||
final policy = AlarmPolicy.resolve(DateTime(2026, 10, 19, 22), 7 * 60 + 45);
|
final policy = AlarmPolicy.resolve(DateTime(2026, 9, 27, 22), 7 * 60 + 58);
|
||||||
expect(hm(policy), '07:35');
|
expect(hm(policy), '07:57');
|
||||||
expect(policy.isOverridden, isFalse);
|
expect(policy.isOverridden, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -5,45 +5,64 @@ String _fmt(int minutes) =>
|
|||||||
'${(minutes ~/ 60).toString().padLeft(2, '0')}:'
|
'${(minutes ~/ 60).toString().padLeft(2, '0')}:'
|
||||||
'${(minutes % 60).toString().padLeft(2, '0')}';
|
'${(minutes % 60).toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
String _on(int y, int m, int d, [Set<String> earned = const {}]) =>
|
||||||
|
_fmt(rampMinutesOn(DateTime(y, m, d), earned));
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('the ramp: 08:00, a minute a day, then 07:00 for good', () {
|
test('the seeds reproduce what already rang: 07:59, 07:58, then 07:57', () {
|
||||||
final cases = <DateTime, String>{
|
expect(_on(2026, 9, 25), '08:00'); // Fri: nothing earned before it
|
||||||
DateTime(2026): '08:00',
|
expect(_on(2026, 9, 26), '07:59'); // Sat
|
||||||
DateTime(2026, 9, 25): '08:00',
|
expect(_on(2026, 9, 27), '07:58'); // Sun
|
||||||
DateTime(2026, 9, 26): '07:59',
|
expect(_on(2026, 9, 28), '07:57'); // Mon
|
||||||
DateTime(2026, 10, 5): '07:50',
|
});
|
||||||
DateTime(2026, 11, 23): '07:01',
|
|
||||||
DateTime(2026, 11, 24): '07:00',
|
test('workdays hold the latest non-workday time', () {
|
||||||
DateTime(2026, 11, 25): '07:00',
|
final earned = {'2026-09-28'}; // Monday dismissed
|
||||||
DateTime(2030, 6): '07:00',
|
expect(_on(2026, 9, 29, earned), '07:57'); // Tue
|
||||||
|
expect(_on(2026, 9, 30, earned), '07:57'); // Wed
|
||||||
|
expect(_on(2026, 10, 1, earned), '07:57'); // Thu
|
||||||
|
expect(_on(2026, 10, 2, earned), '07:56'); // Fri: Monday's minute
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no earned minute (a miss or a free day) means no step', () {
|
||||||
|
// Friday missed: Saturday rings at Friday's time.
|
||||||
|
final earned = {'2026-09-28'};
|
||||||
|
expect(_on(2026, 10, 3, earned), '07:56');
|
||||||
|
// Saturday dismissed, Sunday free (never rings, earns nothing):
|
||||||
|
earned.add('2026-10-03');
|
||||||
|
expect(_on(2026, 10, 4, earned), '07:55');
|
||||||
|
expect(_on(2026, 10, 5, earned), '07:55');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a workday date in the set earns nothing', () {
|
||||||
|
expect(_on(2026, 10, 2, {'2026-09-29', '2026-09-30'}), '07:57');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unparseable keys are ignored', () {
|
||||||
|
expect(_on(2026, 10, 2, {'nonsense', ''}), '07:57');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('07:00 is the floor', () {
|
||||||
|
final earned = {
|
||||||
|
for (var i = 0; i < 200; i++) rampDateKey(DateTime(2026, 10, 2 + i)),
|
||||||
};
|
};
|
||||||
for (final MapEntry(key: day, value: expected) in cases.entries) {
|
expect(_on(2027, 12, 31, earned), '07:00');
|
||||||
expect(_fmt(rampMinutesOn(day)), expected, reason: '$day');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('only the date matters, not the time of day', () {
|
test('only the date matters, not the time of day', () {
|
||||||
expect(
|
expect(
|
||||||
rampMinutesOn(DateTime(2026, 9, 26, 23, 59)),
|
rampMinutesOn(DateTime(2026, 9, 26, 23, 59), const {}),
|
||||||
rampMinutesOn(DateTime(2026, 9, 26)),
|
rampMinutesOn(DateTime(2026, 9, 26), const {}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the DST change on 2026-10-25 costs exactly one minute a day', () {
|
test('the workday set is Tue/Wed/Thu', () {
|
||||||
// Local midnights either side of the fall-back are 25 hours apart; a
|
expect(workdays, {DateTime.tuesday, DateTime.wednesday, DateTime.thursday});
|
||||||
// Duration-based count would see 0 or 2 days somewhere around here.
|
expect(isWorkday(DateTime(2026, 9, 29)), isTrue);
|
||||||
expect(_fmt(rampMinutesOn(DateTime(2026, 10, 24, 23, 30))), '07:31');
|
expect(isWorkday(DateTime(2026, 9, 28)), isFalse);
|
||||||
expect(_fmt(rampMinutesOn(DateTime(2026, 10, 25, 0, 30))), '07:30');
|
|
||||||
expect(_fmt(rampMinutesOn(DateTime(2026, 10, 26, 0, 30))), '07:29');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('never moves later from one day to the next', () {
|
test('date keys are zero-padded ISO dates', () {
|
||||||
var previous = rampStartMinutes;
|
expect(rampDateKey(DateTime(2026, 3, 4, 23)), '2026-03-04');
|
||||||
for (var n = -5; n < 90; n++) {
|
|
||||||
final minutes = rampMinutesOn(DateTime(2026, 9, 25 + n));
|
|
||||||
expect(minutes, lessThanOrEqualTo(previous));
|
|
||||||
expect(minutes, greaterThanOrEqualTo(rampFloorMinutes));
|
|
||||||
previous = minutes;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
48
phone_app/test/services/ramp_progress_test.dart
Normal file
48
phone_app/test/services/ramp_progress_test.dart
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
|
||||||
|
|
||||||
|
test('a fresh install has earned nothing', () async {
|
||||||
|
expect(await RampProgress.load(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a dismissed non-workday morning earns its date, once', () async {
|
||||||
|
expect(
|
||||||
|
await RampProgress.recordDismiss(DateTime(2026, 9, 28, 7, 58)),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(await RampProgress.recordDismiss(DateTime(2026, 9, 28, 9)), isFalse);
|
||||||
|
expect(await RampProgress.load(), {'2026-09-28'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a workday dismiss earns nothing', () async {
|
||||||
|
expect(await RampProgress.recordDismiss(DateTime(2026, 9, 29, 8)), isFalse);
|
||||||
|
expect(await RampProgress.load(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge is a union and drops non-canonical keys', () async {
|
||||||
|
await RampProgress.merge({'2026-09-28'});
|
||||||
|
expect(
|
||||||
|
await RampProgress.merge({'2026-10-02', '2026-9-3', 'junk'}),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(await RampProgress.merge({'2026-10-02'}), isFalse);
|
||||||
|
expect(await RampProgress.load(), {'2026-09-28', '2026-10-02'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupt storage reads as nothing earned', () async {
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||||
|
RampProgress.kEarnedKey: '{not json',
|
||||||
|
});
|
||||||
|
expect(await RampProgress.load(), isEmpty);
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||||
|
RampProgress.kEarnedKey: '{"a": 1}',
|
||||||
|
});
|
||||||
|
expect(await RampProgress.load(), isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||||
import 'package:wake_alarm_sync/services/home_location_settings.dart';
|
import 'package:wake_alarm_sync/services/home_location_settings.dart';
|
||||||
|
import 'package:wake_alarm_sync/services/ramp_progress.dart';
|
||||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||||
import 'package:wake_alarm_sync/services/settings_sync.dart';
|
import 'package:wake_alarm_sync/services/settings_sync.dart';
|
||||||
|
|
||||||
@ -178,4 +179,57 @@ void main() {
|
|||||||
expect(service.field(homeLocationField)?.$2, hlc);
|
expect(service.field(homeLocationField)?.$2, hlc);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ramp earned dates', () {
|
||||||
|
test('local dates are pushed as the union with the logged ones', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
await RampProgress.merge({'2026-09-28'});
|
||||||
|
final service = await _open();
|
||||||
|
await service.setFields({
|
||||||
|
rampEarnedField: ['2026-10-02'],
|
||||||
|
}, _at('2026-10-02T08:00:00Z'));
|
||||||
|
|
||||||
|
await stageLocalSettings(service);
|
||||||
|
|
||||||
|
expect(service.field(rampEarnedField)?.$1, ['2026-09-28', '2026-10-02']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing new locally means nothing pushed', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
await RampProgress.merge({'2026-09-28'});
|
||||||
|
final service = await _open();
|
||||||
|
final hlc = _at('2026-10-02T08:00:00Z');
|
||||||
|
await service.setFields({
|
||||||
|
rampEarnedField: ['2026-09-28', '2026-10-02'],
|
||||||
|
}, hlc);
|
||||||
|
|
||||||
|
await stageLocalSettings(service);
|
||||||
|
|
||||||
|
expect(service.field(rampEarnedField)?.$2, hlc);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reinstall gets every earned date back', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final service = await _open();
|
||||||
|
await service.setFields({
|
||||||
|
rampEarnedField: ['2026-09-28', 'junk', 7],
|
||||||
|
}, _at('2026-10-02T08:00:00Z'));
|
||||||
|
|
||||||
|
await applySyncedSettings(service);
|
||||||
|
|
||||||
|
expect(await RampProgress.load(), {'2026-09-28'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a malformed field earns nothing', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final service = await _open();
|
||||||
|
await service.setFields({
|
||||||
|
rampEarnedField: 'not a list',
|
||||||
|
}, _at('2026-10-02T08:00:00Z'));
|
||||||
|
|
||||||
|
await applySyncedSettings(service);
|
||||||
|
|
||||||
|
expect(await RampProgress.load(), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ dependencies = [
|
|||||||
# falls back to its stdlib GitHub path if the import fails, so a broken
|
# falls back to its stdlib GitHub path if the import fails, so a broken
|
||||||
# install degrades the read instead of killing the per-minute trigger.
|
# install degrades the read instead of killing the per-minute trigger.
|
||||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
||||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays",
|
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python
|
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python
|
||||||
|
|||||||
@ -4,9 +4,9 @@
|
|||||||
# it is missing — but it is required here for mypy/pylint/tests.
|
# it is missing — but it is required here for mypy/pylint/tests.
|
||||||
bandit==1.9.4
|
bandit==1.9.4
|
||||||
codespell==2.4.3
|
codespell==2.4.3
|
||||||
coverage==7.16.1
|
coverage==7.16.2
|
||||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
|
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
|
||||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock
|
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock
|
||||||
mypy==2.3.1
|
mypy==2.3.1
|
||||||
pre-commit==4.6.2
|
pre-commit==4.6.2
|
||||||
|
|||||||
@ -15,12 +15,14 @@ from pathlib import Path
|
|||||||
ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6})
|
ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6})
|
||||||
|
|
||||||
|
|
||||||
# The time the alarm fires at when the phone's alarm cannot be resolved at all
|
# Hour at which the alarm fires when the phone's alarm cannot be resolved at
|
||||||
# (no network *and* no cached read ever written) is ``_ramp.baseline_time`` —
|
# all — no network *and* no cached read ever written. Minute is implicitly 0.
|
||||||
# deliberately the SAME baseline the phone bakes in (``alarm_ramp.dart``), so
|
#
|
||||||
# the two halves agree even with the network down. It used to be a fixed
|
# Deliberately the SAME baseline the phone bakes in (AlarmPolicy.baselineHour),
|
||||||
# 13:00, which meant a Firebase outage silently moved the alarm five hours
|
# so the two halves agree even with the network down. It used to be 13:00,
|
||||||
# later — the one direction the whole design refuses to allow.
|
# 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
|
||||||
|
|
||||||
|
|
||||||
# State file for wake alarm (HMAC-signed)
|
# State file for wake alarm (HMAC-signed)
|
||||||
@ -69,7 +71,7 @@ LOG_DEDUPE_FILE: Path = LOG_STATE_DIR / "last_event.json"
|
|||||||
# SYNC_DEVICES_PREFIX and falls back to this path for a phone that has not
|
# SYNC_DEVICES_PREFIX and falls back to this path for a phone that has not
|
||||||
# yet published under its uuid. Keep reading it until every install has
|
# yet published under its uuid. Keep reading it until every install has
|
||||||
# migrated; deleting it early means a stale phone's alarm silently stops
|
# migrated; deleting it early means a stale phone's alarm silently stops
|
||||||
# being seen, and the trigger falls back to the ramp baseline.
|
# being seen, and the trigger falls back to FALLBACK_ALARM_HOUR.
|
||||||
SYNC_ALARM_PATH: str = "wake-alarm-sync/devices/phone/alarm.json"
|
SYNC_ALARM_PATH: str = "wake-alarm-sync/devices/phone/alarm.json"
|
||||||
|
|
||||||
# The directory holding one subdirectory per publishing device.
|
# The directory holding one subdirectory per publishing device.
|
||||||
@ -112,8 +114,8 @@ SYNC_HTTP_TIMEOUT_SECONDS: float = 10.0
|
|||||||
# phone left one record stamped 2026-08-09, and the PC honoured it faithfully
|
# phone left one record stamped 2026-08-09, and the PC honoured it faithfully
|
||||||
# for three weeks -- a publish that never happens is indistinguishable from one
|
# for three weeks -- a publish that never happens is indistinguishable from one
|
||||||
# that has not changed, so the last successful publish is authoritative
|
# that has not changed, so the last successful publish is authoritative
|
||||||
# forever. Expiring to the ramp baseline is safe precisely because that
|
# forever. Expiring to FALLBACK_ALARM_HOUR is safe precisely because that
|
||||||
# is the phone's own baseline, so a lapsed record degrades to the
|
# constant is the phone's own baseline, so a lapsed record degrades to the
|
||||||
# right answer rather than a guess.
|
# right answer rather than a guess.
|
||||||
PUBLISHED_ALARM_MAX_AGE_SECONDS: float = 14 * 24 * 60 * 60
|
PUBLISHED_ALARM_MAX_AGE_SECONDS: float = 14 * 24 * 60 * 60
|
||||||
|
|
||||||
@ -156,11 +158,6 @@ SESSION_COMPLETED_UNTIL: tuple[int, int] = (11, 0)
|
|||||||
SESSION_NONE_GRACE: timedelta = timedelta(minutes=15)
|
SESSION_NONE_GRACE: timedelta = timedelta(minutes=15)
|
||||||
|
|
||||||
|
|
||||||
# --- Workday stick (Tue/Wed/Thu miss -> tomorrow loses its leniency) ------
|
# Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days
|
||||||
# The same weekdays screen-locker calls "relaxed" (_weekly_check.is_relaxed_day)
|
# themselves are freedays.WORKDAYS, the one set every gate app reads.
|
||||||
# and leetcode-guard prices as cheap (Pricing.doubled_days) -- both already go
|
|
||||||
# easy on exactly these three days, which is why a miss costs nothing today
|
|
||||||
# without this file. Python weekday(): Mon=0 ... Sun=6.
|
|
||||||
STICK_DAYS: frozenset[int] = frozenset({1, 2, 3}) # Tue, Wed, Thu
|
|
||||||
|
|
||||||
WORKDAY_PENALTY_FILE: Path = LOG_STATE_DIR / "workday_penalty.json"
|
WORKDAY_PENALTY_FILE: Path = LOG_STATE_DIR / "workday_penalty.json"
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
"""The baseline wake time, lowered one minute per calendar day to 07:00.
|
|
||||||
|
|
||||||
"Cooking the frog": 2026-09-25 is the last 08:00 morning, 2026-09-26 rings
|
|
||||||
at 07:59, and so on until 2026-11-24 reaches the 07:00 floor, which is then
|
|
||||||
permanent. The time is a pure function of the date the alarm fires on — no
|
|
||||||
counter, no stored start date — so neither a reinstall nor a wiped state file
|
|
||||||
can reset the ramp, and there is nothing that could run away in either
|
|
||||||
direction.
|
|
||||||
|
|
||||||
Every calendar day counts, free days included: counting only rung days would
|
|
||||||
let free days stall the ramp and move its finish date.
|
|
||||||
|
|
||||||
Three copies of this function exist and must agree: this one (PC fallback
|
|
||||||
and clamp), ``phone_app/lib/services/alarm_ramp.dart`` (the policy the phone
|
|
||||||
arms and publishes) and ``AlarmRamp.kt`` (the native re-arm after a dismiss,
|
|
||||||
a free day, a boot or the watchdog). ``tests/test_ramp.py`` parses the other
|
|
||||||
two and fails when their constants drift from these.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, date, datetime
|
|
||||||
|
|
||||||
# The last day at the old 08:00 baseline; each day after it is a minute earlier.
|
|
||||||
RAMP_ANCHOR: date = date(2026, 9, 25)
|
|
||||||
|
|
||||||
# Minutes of day on the anchor date (08:00).
|
|
||||||
RAMP_START_MINUTES: int = 8 * 60
|
|
||||||
|
|
||||||
# The floor the ramp stops at, for good (07:00).
|
|
||||||
RAMP_FLOOR_MINUTES: int = 7 * 60
|
|
||||||
|
|
||||||
|
|
||||||
def baseline_minutes(day: date) -> int:
|
|
||||||
"""Return the baseline wake time on *day*, as minutes after midnight.
|
|
||||||
|
|
||||||
Days before the anchor stay at 08:00 rather than extrapolating later:
|
|
||||||
the ramp only ever moves the alarm earlier.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
day: The local calendar date the alarm fires on.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Minutes after local midnight, between the floor and the start.
|
|
||||||
"""
|
|
||||||
elapsed = max(0, (day - RAMP_ANCHOR).days)
|
|
||||||
return max(RAMP_FLOOR_MINUTES, RAMP_START_MINUTES - elapsed)
|
|
||||||
|
|
||||||
|
|
||||||
def baseline_time(day: date) -> tuple[int, int]:
|
|
||||||
"""Return the baseline wake time on *day* as ``(hour, minute)``.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
day: The local calendar date the alarm fires on.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The hour and minute of the baseline.
|
|
||||||
"""
|
|
||||||
return divmod(baseline_minutes(day), 60)
|
|
||||||
|
|
||||||
|
|
||||||
def local_today() -> date:
|
|
||||||
"""Return today's *local* calendar date — the date the phone rings on.
|
|
||||||
|
|
||||||
Never ``_state._today_str()``, which is UTC: between local midnight and
|
|
||||||
02:00 in summer it names yesterday, a minute later than today's ramp.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The system-local date. Also the seam tests patch.
|
|
||||||
"""
|
|
||||||
return datetime.now(tz=UTC).astimezone().date()
|
|
||||||
@ -34,9 +34,8 @@ import sys
|
|||||||
|
|
||||||
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
||||||
|
|
||||||
from wake_alarm._constants import MORNING_SESSION_FILE
|
from wake_alarm._constants import FALLBACK_ALARM_HOUR, MORNING_SESSION_FILE
|
||||||
from wake_alarm._logging_setup import configure_logging
|
from wake_alarm._logging_setup import configure_logging
|
||||||
from wake_alarm._ramp import baseline_time
|
|
||||||
from wake_alarm._session_payload import newest_session
|
from wake_alarm._session_payload import newest_session
|
||||||
from wake_alarm._session_policy import to_entry
|
from wake_alarm._session_policy import to_entry
|
||||||
from wake_alarm._sync import get_alarm
|
from wake_alarm._sync import get_alarm
|
||||||
@ -100,8 +99,8 @@ def refresh(now: datetime) -> dict[str, object] | None:
|
|||||||
today=today,
|
today=today,
|
||||||
refreshed_at=now.timestamp(),
|
refreshed_at=now.timestamp(),
|
||||||
# Same clamp as the trigger: the phone may only move the alarm earlier
|
# Same clamp as the trigger: the phone may only move the alarm earlier
|
||||||
# than today's ramp baseline, so a later value never widens the grace.
|
# than the baseline, so a later synced value never widens the grace.
|
||||||
alarm=min((alarm.hour, alarm.minute), baseline_time(now.date())),
|
alarm=min((alarm.hour, alarm.minute), (FALLBACK_ALARM_HOUR, 0)),
|
||||||
)
|
)
|
||||||
if not write_entry(entry):
|
if not write_entry(entry):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -33,10 +33,10 @@ from wake_alarm._alarm_payload import in_range
|
|||||||
from wake_alarm._constants import (
|
from wake_alarm._constants import (
|
||||||
ALARM_CACHE_FILE,
|
ALARM_CACHE_FILE,
|
||||||
ALARM_CACHE_MAX_AGE_SECONDS,
|
ALARM_CACHE_MAX_AGE_SECONDS,
|
||||||
|
FALLBACK_ALARM_HOUR,
|
||||||
PUBLISHED_ALARM_MAX_AGE_SECONDS,
|
PUBLISHED_ALARM_MAX_AGE_SECONDS,
|
||||||
)
|
)
|
||||||
from wake_alarm._logging_setup import configure_logging
|
from wake_alarm._logging_setup import configure_logging
|
||||||
from wake_alarm._ramp import baseline_time, local_today
|
|
||||||
from wake_alarm._sync_transport import (
|
from wake_alarm._sync_transport import (
|
||||||
_FIREBASE_AVAILABLE,
|
_FIREBASE_AVAILABLE,
|
||||||
_fetch_firebase_alarm,
|
_fetch_firebase_alarm,
|
||||||
@ -167,7 +167,7 @@ def get_alarm(*, max_cache_age_seconds: float = ALARM_CACHE_MAX_AGE_SECONDS) ->
|
|||||||
1. A cache entry younger than *max_cache_age_seconds* (no network).
|
1. A cache entry younger than *max_cache_age_seconds* (no network).
|
||||||
2. A live fetch, Firebase first (cached on success).
|
2. A live fetch, Firebase first (cached on success).
|
||||||
3. Any cache entry, however stale — better an old alarm than none.
|
3. Any cache entry, however stale — better an old alarm than none.
|
||||||
4. Today's ramp baseline (``_ramp.baseline_time``).
|
4. ``FALLBACK_ALARM_HOUR:00``.
|
||||||
|
|
||||||
A record the phone published longer ago than
|
A record the phone published longer ago than
|
||||||
``PUBLISHED_ALARM_MAX_AGE_SECONDS`` is skipped at *every* step, including
|
``PUBLISHED_ALARM_MAX_AGE_SECONDS`` is skipped at *every* step, including
|
||||||
@ -206,9 +206,11 @@ def get_alarm(*, max_cache_age_seconds: float = ALARM_CACHE_MAX_AGE_SECONDS) ->
|
|||||||
_logger.info("Using stale cached alarm — sync read failed")
|
_logger.info("Using stale cached alarm — sync read failed")
|
||||||
return Alarm(cached[0], cached[1], "stale-cache")
|
return Alarm(cached[0], cached[1], "stale-cache")
|
||||||
|
|
||||||
hour, minute = baseline_time(local_today())
|
_logger.info(
|
||||||
_logger.info("No synced or cached alarm — falling back to %02d:%02d", hour, minute)
|
"No synced or cached alarm — falling back to %02d:00",
|
||||||
return Alarm(hour, minute, "fallback")
|
FALLBACK_ALARM_HOUR,
|
||||||
|
)
|
||||||
|
return Alarm(FALLBACK_ALARM_HOUR, 0, "fallback")
|
||||||
|
|
||||||
|
|
||||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
|||||||
@ -109,7 +109,7 @@ def fetch_firebase_logs() -> list[tuple[str, str]] | None:
|
|||||||
Discovers device directories rather than reading one fixed path: the
|
Discovers device directories rather than reading one fixed path: the
|
||||||
phone's device id is migrating from the constant "phone" to a per-install
|
phone's device id is migrating from the constant "phone" to a per-install
|
||||||
uuid, and a reader pinned to the old path would stop seeing the alarm the
|
uuid, and a reader pinned to the old path would stop seeing the alarm the
|
||||||
moment the phone migrated — falling back to the ramp baseline, i.e.
|
moment the phone migrated — falling back to FALLBACK_ALARM_HOUR, i.e.
|
||||||
waking the user at the wrong time. Listing covers both ids, and
|
waking the user at the wrong time. Listing covers both ids, and
|
||||||
SYNC_ALARM_PATH remains as a last resort for a backend that cannot list.
|
SYNC_ALARM_PATH remains as a last resort for a backend that cannot list.
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ must be built in the same timezone the user reads off their phone.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@ -30,11 +30,11 @@ import freedays
|
|||||||
|
|
||||||
from wake_alarm._constants import (
|
from wake_alarm._constants import (
|
||||||
ALARM_DAYS,
|
ALARM_DAYS,
|
||||||
|
FALLBACK_ALARM_HOUR,
|
||||||
TRIGGER_CATCHUP_MINUTES,
|
TRIGGER_CATCHUP_MINUTES,
|
||||||
TRIGGER_TARGET_UNIT,
|
TRIGGER_TARGET_UNIT,
|
||||||
)
|
)
|
||||||
from wake_alarm._logging_setup import configure_logging
|
from wake_alarm._logging_setup import configure_logging
|
||||||
from wake_alarm._ramp import RAMP_START_MINUTES, baseline_time
|
|
||||||
from wake_alarm._state import (
|
from wake_alarm._state import (
|
||||||
save_trigger_fired,
|
save_trigger_fired,
|
||||||
was_trigger_fired_today,
|
was_trigger_fired_today,
|
||||||
@ -170,42 +170,37 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def _clamp_to_baseline(alarm: Alarm, day: date) -> Alarm:
|
def _clamp_to_baseline(alarm: Alarm) -> Alarm:
|
||||||
"""Refuse to let any resolved alarm land later than *day*'s ramp baseline.
|
"""Refuse to let any resolved alarm land later than the baseline.
|
||||||
|
|
||||||
The phone arms at ``_ramp.baseline_time`` and only ever ratchets earlier,
|
The phone bakes in ``FALLBACK_ALARM_HOUR`` and only ever ratchets *earlier*,
|
||||||
so honour an earlier value and clamp a later one. This is the PC half of
|
so a synced value later than the baseline means something is wrong — a
|
||||||
the watchdog: without it the one thing the design forbids — the alarm
|
stale record from before the policy landed, a half-written payload, or a
|
||||||
silently moving later — would be reachable through sync.
|
tampered store. Whatever the cause, the safe direction is unambiguous:
|
||||||
|
honour it if it is earlier, clamp it if it is later.
|
||||||
|
|
||||||
Up to 08:00 a later value is expected lag, not tampering: the phone
|
This is the PC half of the watchdog. Without it the PC would faithfully
|
||||||
publishes when the app runs, so yesterday's record is a minute behind
|
reproduce a bad remote value, and the one thing the design forbids —
|
||||||
today's ramp. That logs at INFO (this runs every tick); only a value later
|
the alarm silently moving later — would be reachable through sync.
|
||||||
than the old 08:00 baseline — stale or tampered — is worth a WARNING.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
alarm: The alarm as resolved by ``get_alarm``.
|
alarm: The alarm as resolved by ``get_alarm``.
|
||||||
day: The local date the alarm fires on.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The same alarm, or the ramp baseline when the resolved time is later.
|
The same alarm, or the baseline when the resolved time is later.
|
||||||
"""
|
"""
|
||||||
base = baseline_time(day)
|
if (alarm.hour, alarm.minute) <= (FALLBACK_ALARM_HOUR, 0):
|
||||||
if (alarm.hour, alarm.minute) <= base:
|
|
||||||
return alarm
|
return alarm
|
||||||
level = logging.INFO
|
_logger.warning(
|
||||||
if alarm.hour * 60 + alarm.minute > RAMP_START_MINUTES:
|
"Resolved alarm %02d:%02d is later than the %02d:00 baseline "
|
||||||
level = logging.WARNING
|
"(source=%s) — clamping. The phone can only move it earlier, so a "
|
||||||
_logger.log(
|
"later value means a stale or tampered record.",
|
||||||
level,
|
|
||||||
"Resolved alarm %02d:%02d is later than today's %02d:%02d baseline "
|
|
||||||
"(source=%s) — clamping.",
|
|
||||||
alarm.hour,
|
alarm.hour,
|
||||||
alarm.minute,
|
alarm.minute,
|
||||||
*base,
|
FALLBACK_ALARM_HOUR,
|
||||||
alarm.source,
|
alarm.source,
|
||||||
)
|
)
|
||||||
return Alarm(*base, f"clamped-{alarm.source}")
|
return Alarm(FALLBACK_ALARM_HOUR, 0, f"clamped-{alarm.source}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@ -214,7 +209,7 @@ def main() -> None:
|
|||||||
args = _parse_args(sys.argv[1:])
|
args = _parse_args(sys.argv[1:])
|
||||||
|
|
||||||
now = _now()
|
now = _now()
|
||||||
alarm = _clamp_to_baseline(get_alarm(), now.date())
|
alarm = _clamp_to_baseline(get_alarm())
|
||||||
should_fire, reason = _decide(now, alarm)
|
should_fire, reason = _decide(now, alarm)
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
|
|||||||
@ -24,15 +24,15 @@ import logging
|
|||||||
import freedays
|
import freedays
|
||||||
from gatelock.log_integrity import compute_entry_hmac
|
from gatelock.log_integrity import compute_entry_hmac
|
||||||
|
|
||||||
from wake_alarm._constants import STICK_DAYS, WORKDAY_PENALTY_FILE
|
from wake_alarm._constants import WORKDAY_PENALTY_FILE
|
||||||
from wake_alarm._session_policy import NONE_OUTCOME
|
from wake_alarm._session_policy import NONE_OUTCOME
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def is_stick_day(day: date) -> bool:
|
def is_stick_day(day: date) -> bool:
|
||||||
"""Tuesday/Wednesday/Thursday -- the same set already treated as lenient."""
|
"""Tuesday/Wednesday/Thursday: ``freedays.WORKDAYS``, the shared set."""
|
||||||
return day.weekday() in STICK_DAYS
|
return freedays.is_workday(day)
|
||||||
|
|
||||||
|
|
||||||
def is_workday_miss(entry: dict[str, object], now: datetime) -> bool:
|
def is_workday_miss(entry: dict[str, object], now: datetime) -> bool:
|
||||||
|
|||||||
@ -1,91 +0,0 @@
|
|||||||
"""Tests for the daily ramp — and that its three copies agree."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from wake_alarm._ramp import (
|
|
||||||
RAMP_ANCHOR,
|
|
||||||
RAMP_FLOOR_MINUTES,
|
|
||||||
RAMP_START_MINUTES,
|
|
||||||
baseline_minutes,
|
|
||||||
baseline_time,
|
|
||||||
local_today,
|
|
||||||
)
|
|
||||||
|
|
||||||
_PHONE = Path(__file__).resolve().parents[2] / "phone_app"
|
|
||||||
_DART = _PHONE / "lib/services/alarm_ramp.dart"
|
|
||||||
_KOTLIN = _PHONE / "android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmRamp.kt"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBaseline:
|
|
||||||
"""A minute earlier per calendar day, from 08:00 to a permanent 07:00."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("day", "expected"),
|
|
||||||
[
|
|
||||||
(date(2026, 1, 1), (8, 0)),
|
|
||||||
(date(2026, 9, 25), (8, 0)),
|
|
||||||
(date(2026, 9, 26), (7, 59)),
|
|
||||||
(date(2026, 10, 24), (7, 31)),
|
|
||||||
(date(2026, 10, 25), (7, 30)),
|
|
||||||
(date(2026, 10, 26), (7, 29)),
|
|
||||||
(date(2026, 11, 23), (7, 1)),
|
|
||||||
(date(2026, 11, 24), (7, 0)),
|
|
||||||
(date(2026, 11, 25), (7, 0)),
|
|
||||||
(date(2030, 6, 1), (7, 0)),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_the_ramp(self, day: date, expected: tuple[int, int]) -> None:
|
|
||||||
"""Before the anchor stays 08:00; the floor holds for good."""
|
|
||||||
assert baseline_time(day) == expected
|
|
||||||
|
|
||||||
def test_never_moves_later(self) -> None:
|
|
||||||
"""Day over day, the baseline is monotonically non-increasing."""
|
|
||||||
days = [date.fromordinal(RAMP_ANCHOR.toordinal() + n) for n in range(-5, 90)]
|
|
||||||
minutes = [baseline_minutes(d) for d in days]
|
|
||||||
assert minutes == sorted(minutes, reverse=True)
|
|
||||||
|
|
||||||
def test_local_today_is_a_date(self) -> None:
|
|
||||||
"""The seam returns the system-local calendar date."""
|
|
||||||
assert isinstance(local_today(), date)
|
|
||||||
|
|
||||||
|
|
||||||
def _grab(pattern: str, text: str) -> tuple[str, ...]:
|
|
||||||
match = re.search(pattern, text)
|
|
||||||
assert match is not None, pattern
|
|
||||||
return match.groups()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCopiesAgree:
|
|
||||||
"""The Dart and Kotlin copies carry the same constants as this one.
|
|
||||||
|
|
||||||
Three copies of a pure function are cheap; three copies that silently
|
|
||||||
disagree mean the phone rings at one time and the PC fires at another.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_dart(self) -> None:
|
|
||||||
"""``alarm_ramp.dart`` matches ``_ramp.py``."""
|
|
||||||
text = _DART.read_text()
|
|
||||||
y, m, d = _grab(r"rampAnchor = DateTime\.utc\((\d+), (\d+), (\d+)\)", text)
|
|
||||||
assert date(int(y), int(m), int(d)) == RAMP_ANCHOR
|
|
||||||
h, mi = _grab(r"rampStartMinutes = (\d+) \* (\d+);", text)
|
|
||||||
assert int(h) * int(mi) == RAMP_START_MINUTES
|
|
||||||
h, mi = _grab(r"rampFloorMinutes = (\d+) \* (\d+);", text)
|
|
||||||
assert int(h) * int(mi) == RAMP_FLOOR_MINUTES
|
|
||||||
|
|
||||||
def test_kotlin(self) -> None:
|
|
||||||
"""``AlarmRamp.kt`` matches ``_ramp.py``."""
|
|
||||||
text = _KOTLIN.read_text()
|
|
||||||
y, m, d = _grab(
|
|
||||||
r"ANCHOR: LocalDate = LocalDate\.of\((\d+), (\d+), (\d+)\)", text
|
|
||||||
)
|
|
||||||
assert date(int(y), int(m), int(d)) == RAMP_ANCHOR
|
|
||||||
h, mi = _grab(r"START_MINUTES = (\d+) \* (\d+)", text)
|
|
||||||
assert int(h) * int(mi) == RAMP_START_MINUTES
|
|
||||||
h, mi = _grab(r"FLOOR_MINUTES = (\d+) \* (\d+)", text)
|
|
||||||
assert int(h) * int(mi) == RAMP_FLOOR_MINUTES
|
|
||||||
@ -163,16 +163,6 @@ class TestRefresh:
|
|||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry["exempt_until"] == _local("2026-09-19T08:15:00").isoformat()
|
assert entry["exempt_until"] == _local("2026-09-19T08:15:00").isoformat()
|
||||||
|
|
||||||
def test_the_clamp_follows_the_ramp(self) -> None:
|
|
||||||
# 2026-10-05 is 07:50 on the ramp, so a synced 08:00 grants 08:05.
|
|
||||||
with (
|
|
||||||
patch("wake_alarm._session.fetch_firebase_logs", return_value=[]),
|
|
||||||
patch("wake_alarm._session.get_alarm", return_value=Alarm(8, 0, "cache")),
|
|
||||||
):
|
|
||||||
entry = refresh(datetime(2026, 10, 5, 7, 0).astimezone())
|
|
||||||
assert entry is not None
|
|
||||||
assert entry["exempt_until"] == _local("2026-10-05T08:05:00").isoformat()
|
|
||||||
|
|
||||||
def test_an_earlier_synced_alarm_shortens_the_grace(self) -> None:
|
def test_an_earlier_synced_alarm_shortens_the_grace(self) -> None:
|
||||||
with (
|
with (
|
||||||
patch("wake_alarm._session.fetch_firebase_logs", return_value=[]),
|
patch("wake_alarm._session.fetch_firebase_logs", return_value=[]),
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@ -10,6 +9,9 @@ import urllib.error
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from wake_alarm._constants import (
|
||||||
|
FALLBACK_ALARM_HOUR,
|
||||||
|
)
|
||||||
from wake_alarm._sync import (
|
from wake_alarm._sync import (
|
||||||
Alarm,
|
Alarm,
|
||||||
_fetch_github_alarm,
|
_fetch_github_alarm,
|
||||||
@ -224,11 +226,12 @@ class TestGetAlarm:
|
|||||||
"stale-cache",
|
"stale-cache",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_falls_back_to_the_ramp(self) -> None:
|
def test_falls_back_to_constant(self) -> None:
|
||||||
"""With no cache and no network, today's ramp baseline is used."""
|
"""With no cache and no network, the fixed fallback hour is used."""
|
||||||
with (
|
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=None):
|
||||||
patch("wake_alarm._sync.fetch_remote_alarm", return_value=None),
|
|
||||||
patch("wake_alarm._sync.local_today", return_value=date(2026, 10, 5)),
|
|
||||||
):
|
|
||||||
alarm = get_alarm()
|
alarm = get_alarm()
|
||||||
assert (alarm.hour, alarm.minute, alarm.source) == (7, 50, "fallback")
|
assert (alarm.minute, alarm.source) == (0, "fallback")
|
||||||
|
# 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
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@ -11,6 +10,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from wake_alarm._constants import (
|
from wake_alarm._constants import (
|
||||||
|
FALLBACK_ALARM_HOUR,
|
||||||
PUBLISHED_ALARM_MAX_AGE_SECONDS,
|
PUBLISHED_ALARM_MAX_AGE_SECONDS,
|
||||||
)
|
)
|
||||||
from wake_alarm._sync import (
|
from wake_alarm._sync import (
|
||||||
@ -84,10 +84,10 @@ class TestLapsedPublishedAlarm:
|
|||||||
"wake_alarm._sync.fetch_remote_alarm",
|
"wake_alarm._sync.fetch_remote_alarm",
|
||||||
return_value=(7, 0, ancient),
|
return_value=(7, 0, ancient),
|
||||||
),
|
),
|
||||||
patch("wake_alarm._sync.local_today", return_value=date(2026, 10, 5)),
|
|
||||||
):
|
):
|
||||||
alarm = get_alarm()
|
alarm = get_alarm()
|
||||||
assert (alarm.hour, alarm.minute, alarm.source) == (7, 50, "fallback")
|
assert alarm.source == "fallback"
|
||||||
|
assert alarm.hour == FALLBACK_ALARM_HOUR
|
||||||
|
|
||||||
def test_a_recent_remote_is_used(self, tmp_path: Path) -> None:
|
def test_a_recent_remote_is_used(self, tmp_path: Path) -> None:
|
||||||
"""A freshly stamped record is honoured as normal."""
|
"""A freshly stamped record is honoured as normal."""
|
||||||
|
|||||||
@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import datetime
|
||||||
import logging
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from wake_alarm._constants import FALLBACK_ALARM_HOUR
|
||||||
from wake_alarm._sync import Alarm
|
from wake_alarm._sync import Alarm
|
||||||
from wake_alarm._trigger import (
|
from wake_alarm._trigger import (
|
||||||
_clamp_to_baseline,
|
_clamp_to_baseline,
|
||||||
@ -38,52 +38,27 @@ def _ungated() -> None:
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
# 2026-09-26 is the ramp's first step: 07:59.
|
|
||||||
_DAY = date(2026, 9, 26)
|
|
||||||
|
|
||||||
|
|
||||||
class TestClampToBaseline:
|
class TestClampToBaseline:
|
||||||
"""The PC-side watchdog: a synced alarm may never land later than the ramp."""
|
"""The PC-side watchdog: a synced alarm may never land later than 08:00."""
|
||||||
|
|
||||||
def test_an_earlier_alarm_passes_through_untouched(self) -> None:
|
def test_an_earlier_alarm_passes_through_untouched(self) -> None:
|
||||||
"""The phone ratchets earlier, so earlier values are legitimate."""
|
"""The phone ratchets earlier, so earlier values are legitimate."""
|
||||||
alarm = Alarm(6, 30, "remote")
|
alarm = Alarm(6, 30, "remote")
|
||||||
assert _clamp_to_baseline(alarm, _DAY) == alarm
|
assert _clamp_to_baseline(alarm) == alarm
|
||||||
|
|
||||||
def test_the_baseline_itself_passes_through(self) -> None:
|
def test_the_baseline_itself_passes_through(self) -> None:
|
||||||
"""Equal to the day's baseline is not later than it."""
|
"""Equal to the baseline is not later than it."""
|
||||||
alarm = Alarm(7, 59, "remote")
|
alarm = Alarm(FALLBACK_ALARM_HOUR, 0, "remote")
|
||||||
assert _clamp_to_baseline(alarm, _DAY) == alarm
|
assert _clamp_to_baseline(alarm) == alarm
|
||||||
|
|
||||||
def test_a_later_alarm_is_clamped_to_the_baseline(self) -> None:
|
def test_a_later_alarm_is_clamped_to_the_baseline(self) -> None:
|
||||||
"""The one direction the design forbids must not be reachable."""
|
"""The one direction the design forbids must not be reachable."""
|
||||||
clamped = _clamp_to_baseline(Alarm(13, 0, "remote"), _DAY)
|
clamped = _clamp_to_baseline(Alarm(13, 0, "remote"))
|
||||||
assert (clamped.hour, clamped.minute) == (7, 59)
|
assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0)
|
||||||
assert clamped.source == "clamped-remote"
|
assert clamped.source == "clamped-remote"
|
||||||
|
|
||||||
def test_before_the_ramp_the_baseline_is_eight(self) -> None:
|
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."""
|
"""08:30 is later than 08:00, so the minute matters too."""
|
||||||
clamped = _clamp_to_baseline(Alarm(8, 30, "cache"), date(2026, 9, 1))
|
clamped = _clamp_to_baseline(Alarm(FALLBACK_ALARM_HOUR, 30, "cache"))
|
||||||
assert (clamped.hour, clamped.minute) == (8, 0)
|
assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0)
|
||||||
assert clamped.source == "clamped-cache"
|
assert clamped.source == "clamped-cache"
|
||||||
|
|
||||||
def test_yesterdays_time_is_clamped_quietly(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""A record a minute behind the ramp is lag, not tampering: INFO only.
|
|
||||||
|
|
||||||
This runs every tick, so a WARNING here would be 1440 lines a day on
|
|
||||||
every morning the phone app was not opened.
|
|
||||||
"""
|
|
||||||
with caplog.at_level(logging.INFO, logger="wake_alarm._trigger"):
|
|
||||||
clamped = _clamp_to_baseline(Alarm(8, 0, "remote"), _DAY)
|
|
||||||
assert (clamped.hour, clamped.minute) == (7, 59)
|
|
||||||
assert [r.levelno for r in caplog.records] == [logging.INFO]
|
|
||||||
|
|
||||||
def test_later_than_eight_still_warns(
|
|
||||||
self, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""Past the old 08:00 baseline no phone could have published it."""
|
|
||||||
with caplog.at_level(logging.INFO, logger="wake_alarm._trigger"):
|
|
||||||
_clamp_to_baseline(Alarm(8, 1, "remote"), _DAY)
|
|
||||||
assert [r.levelno for r in caplog.records] == [logging.WARNING]
|
|
||||||
|
|||||||
37
wake_alarm/tests/test_workday_mirrors.py
Normal file
37
wake_alarm/tests/test_workday_mirrors.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"""The Dart and Kotlin copies of the workdays agree with ``freedays.WORKDAYS``.
|
||||||
|
|
||||||
|
The phone code cannot import the Python package that owns the definition, so
|
||||||
|
it keeps literal copies; this is the check that keeps them honest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
import freedays
|
||||||
|
|
||||||
|
_PHONE = Path(__file__).resolve().parents[2] / "phone_app"
|
||||||
|
_KOTLIN = _PHONE / "android/app/src/main/kotlin/com/kuhy/wake_alarm_sync"
|
||||||
|
_NAMES = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
|
||||||
|
|
||||||
|
|
||||||
|
def _weekdays(block: str) -> frozenset[int]:
|
||||||
|
"""Map the weekday names written in *block* to Python weekday() numbers."""
|
||||||
|
return frozenset(i for i, name in enumerate(_NAMES) if name in block.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def test_dart_ramp_workdays() -> None:
|
||||||
|
"""``alarm_ramp.dart``'s ``workdays`` is ``freedays.WORKDAYS``."""
|
||||||
|
text = (_PHONE / "lib/services/alarm_ramp.dart").read_text()
|
||||||
|
match = re.search(r"const Set<int> workdays = \{(.*?)\};", text, re.DOTALL)
|
||||||
|
assert match is not None
|
||||||
|
assert _weekdays(match.group(1)) == freedays.WORKDAYS
|
||||||
|
|
||||||
|
|
||||||
|
def test_kotlin_workday_stick() -> None:
|
||||||
|
"""``WorkdayStick.kt``'s ``STICK_DAYS`` is ``freedays.WORKDAYS``."""
|
||||||
|
text = (_KOTLIN / "WorkdayStick.kt").read_text()
|
||||||
|
match = re.search(r"STICK_DAYS =\s*setOf\((.*?)\)", text, re.DOTALL)
|
||||||
|
assert match is not None
|
||||||
|
assert _weekdays(match.group(1)) == freedays.WORKDAYS
|
||||||
Loading…
Reference in New Issue
Block a user