mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:07:03 +01:00
Compare commits
9 Commits
v1.0.0-bui
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 89e6eed105 | |||
| 06b70ab312 | |||
| 10938ed012 | |||
| f182453567 | |||
| 537a3a79e4 | |||
| 672eaf8aba | |||
| 13f6e3f5d2 | |||
| 5525ca6f95 | |||
| 58c4260b6e |
@ -8,6 +8,7 @@ default_language_version:
|
||||
default_stages: [pre-commit]
|
||||
fail_fast: false
|
||||
|
||||
default_install_hook_types: [pre-commit, pre-push, post-commit, post-merge, post-rewrite]
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
@ -105,10 +106,10 @@ repos:
|
||||
# direct URLs for one distribution, so any pin that drifts from
|
||||
# the manifest becomes an unresolvable hook environment rather
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays
|
||||
# Test suites intentionally use patterns (protected-access, magic-value
|
||||
# comparisons) that don't belong in the source-code 10/10 gate.
|
||||
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
|
||||
@ -186,6 +187,30 @@ repos:
|
||||
language: system
|
||||
files: \.(md|markdown)$
|
||||
|
||||
- id: todo-closer
|
||||
name: close Todo trailer tasks
|
||||
entry: scripts/todo_hooks.sh closer
|
||||
language: system
|
||||
stages: [post-commit]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
- id: todo-import-pr-merge
|
||||
name: import PR Todo sections (pull)
|
||||
entry: scripts/todo_hooks.sh import-pr post-merge
|
||||
language: system
|
||||
stages: [post-merge]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
- id: todo-import-pr-rewrite
|
||||
name: import PR Todo sections (pull --rebase)
|
||||
entry: scripts/todo_hooks.sh import-pr post-rewrite
|
||||
language: system
|
||||
stages: [post-rewrite]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
|
||||
# so the question is mechanically answerable at all.
|
||||
#
|
||||
|
||||
@ -82,8 +82,18 @@ cutoffs, neither imports `crdt_sync`, neither talks to Firebase:
|
||||
**A PC booted mid-morning** has a missing or yesterday's file until the
|
||||
timer's `Persistent=true` catch-up lands, and that needs the network. Only
|
||||
the enforce path, only inside 05:00–11:00, and only when the file is not
|
||||
today's, both consumers retry for ≤ 30 s (5 s steps) and then proceed as
|
||||
today's, both consumers retry for ≤ 180 s (5 s steps) and then proceed as
|
||||
normal with a WARNING. Status paths never wait; tests pin the retry to 0.
|
||||
The refresher itself retries an unreadable backend every 5 s for 150 s
|
||||
(`REFRESH_RETRY_SECONDS`) before exiting 1 with a WARNING: a PC booted at
|
||||
07:48 had no DNS for its first seconds, and the old single try left the next
|
||||
chance at the 07:50 tick (2026-11-07).
|
||||
|
||||
**`next_step`** (signed with the rest): while the session is `in_progress`,
|
||||
the label of the first leg not in the phone's `passed` map, else `null`.
|
||||
Names and labels live in `_session_legs.py` and are pinned to the phone's
|
||||
`SessionLeg` by `wake_alarm/tests/fixtures/session_legs.json`, asserted by a
|
||||
Python and a Dart test.
|
||||
|
||||
## Known limits (v1, deliberate)
|
||||
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
# Wake-alarm hardening — residue
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
|
||||
Phases 0-7 shipped. The PC alarm window, gate and `_alarm.py` were deleted in
|
||||
`e67cef0` (2026-08-30), so the old "item 8 mutual gating" (PC half) no longer
|
||||
has anything to gate and was dropped. Context: `DOCS-wake-enforcement.md`.
|
||||
|
||||
## Remaining
|
||||
|
||||
1. **Item 13, black screen.** Host-only, other repo
|
||||
(`~/src/testsAndMisc/linux_configuration`). The original plan file
|
||||
(`~/.claude/plans/wake-alarm-still-does-witty-toucan.md`) is gone, so the
|
||||
symptom is no longer written down: needs kuhy to restate it, or drop it.
|
||||
2. **Two-tag dismiss sequence, end to end on the Pixel.** Needs kuhy: a second
|
||||
physical NFC tag (only one is registered, so every on-device run fell
|
||||
through to the 5-minute bypass).
|
||||
3. **Locked-keyguard dismiss, run to completion.** Needs kuhy: a run with a
|
||||
locked phone (PIN-prompt path is implemented but unproven).
|
||||
|
||||
## done
|
||||
|
||||
Items 1-3 each confirmed on the phone or dropped by kuhy; then delete this file.
|
||||
@ -1,63 +0,0 @@
|
||||
# Hardware ideas: defeat a fully-powered-off phone
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
|
||||
Both items below were scoped during the 2026-09-23 workday-stick session
|
||||
(`DOCS-morning-session-pc.md`) and explicitly deferred out of scope for that
|
||||
session: no existing firmware project and no owned wearable, both need a real
|
||||
purchase before any code gets written. Written here instead of
|
||||
`~/src/todo/BACKLOG.md` because that file is gitignored/export-only from the
|
||||
todo app and a manual edit there is silently lost on the next export — this
|
||||
is the durable copy.
|
||||
|
||||
## Item 1 — second wake-alarm device: rings independently of the phone's power state
|
||||
|
||||
### what
|
||||
An ESP32 + buzzer that polls the same Firebase alarm/session record
|
||||
wake-alarm already reads and keeps ringing until it sees a dismiss or a
|
||||
started session — so powering the phone off can no longer silently kill the
|
||||
whole wake attempt the way it does today.
|
||||
|
||||
### where
|
||||
new repo (no existing ESP32/PlatformIO/Arduino project anywhere in `~/src`
|
||||
to build on — checked). Firebase-only, per the "new syncing apps are
|
||||
Firebase-only" rule.
|
||||
|
||||
### must
|
||||
- must not require the phone to relay anything — the whole point is
|
||||
surviving the phone being off.
|
||||
- optional: a physical snooze/dismiss button, since the phone's own dismiss
|
||||
gate isn't reachable from the device.
|
||||
|
||||
### done
|
||||
On a Tue/Wed/Thu, power the phone off right at the alarm and the ESP32
|
||||
still rings, and keeps ringing, until the phone's session shows a start.
|
||||
|
||||
### verify
|
||||
Bench test first (USB power, no case), then a real overnight run.
|
||||
|
||||
### read first
|
||||
- `DOCS-phone-alarm.md` (the Firebase alarm schema to poll)
|
||||
- `DOCS-morning-session-pc.md` (why this exists: the workday-stick project,
|
||||
2026-09-23)
|
||||
|
||||
## Item 2 — wearable wake-defeat check
|
||||
|
||||
### what
|
||||
A cheap fitness band whose own sleep log (stored on the band, synced later)
|
||||
can show "asleep again after the alarm rang" even when the phone was fully
|
||||
off and missed the whole thing.
|
||||
|
||||
### where
|
||||
new app or an extension of `phone_app/`, once a device is picked.
|
||||
|
||||
### must
|
||||
- must not: buy or integrate anything before checking Gadgetbridge's
|
||||
current supported-device list — research first, kuhy owns no wearable and
|
||||
none is integrated anywhere in `~/src` today (checked).
|
||||
|
||||
### done
|
||||
Not yet scoped — picking a supported device is the blocking step.
|
||||
|
||||
### verify
|
||||
Blank — desktop, once there's something to verify.
|
||||
@ -1,90 +0,0 @@
|
||||
# Wake enforcement v2 — follow-ups for the next session
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
|
||||
Handoff from the 2026-10-01 session that built v2. Read
|
||||
`DOCS-wake-enforcement.md` (the contract + verification status) and
|
||||
`AGENTS.md` first. Start **after the morning of 2026-10-02** so there is real
|
||||
evidence to check.
|
||||
|
||||
## Prompt to paste into the new session
|
||||
|
||||
> Work through ~/src/wake-alarm/TODO-wake-enforcement-v2-followups.md top to
|
||||
> bottom. Verify each item yourself on the real target (phone 23181JEGR08034,
|
||||
> this PC) before calling it done, then delete the TODO file in the final
|
||||
> commit.
|
||||
|
||||
## 1. Check the first real morning (2026-10-02, Fri, carrot day)
|
||||
|
||||
- `python -m wake_alarm._audit 2026-10-02` (and the 11:05 file in
|
||||
`~/data/wake-alarm/incidents/`). Expected: phone `alarm_fired`
|
||||
(`reason=daily`), `ring_started_published` with `source=alarm` (headless
|
||||
engine, release build — never verified in release before) or
|
||||
`ring_screen`, `stick_check` verdict for a non-stick day, no fallback ring
|
||||
unless the phone really was silent.
|
||||
- `adb logcat` for `WakeAlarmHeadless` / `WakeAlarmStick*` if anything is
|
||||
missing. Firebase fields `ring_started`, `phone_events` must be non-empty
|
||||
(`enforcement_view(fetch_firebase_logs())`).
|
||||
- Any MISMATCH in the report is a bug to fix, not a note.
|
||||
|
||||
## 2. focus-owner reacting to a real lockdown (needs kuhy's OK + timing)
|
||||
|
||||
Never verified on the Pixel. Either wait for the first real Tue/Wed/Thu miss
|
||||
and read focus-owner's Debug log (`LOCKDOWN_ACTIVATED` with the through-date,
|
||||
then a `WORKDAY_LOCKDOWN` pass), or — with kuhy's explicit OK, at a time he
|
||||
names — trigger one and lift it with the escape button (logs an escape).
|
||||
|
||||
## 3. Root ring vs a card held busy by PipeWire
|
||||
|
||||
Host test 2026-10-01: `wake-alarm-ring-alsa.service` got `Device or resource
|
||||
busy` while the user's PipeWire held card `Generic_1` (ALC897). kuhy approved
|
||||
killing the holder (`fuser -k -TERM /dev/snd/pcmC<N>D0p` after 3 busy opens,
|
||||
in `scripts/pc/ring_alsa.sh`), but the auto-mode classifier refused the edit
|
||||
twice. **kuhy must add a permission rule first**, or choose the non-killing
|
||||
alternative: when the root unit cannot open the card, `_ring_player` falls
|
||||
back to `PipeWireBackend` (PipeWire holds the card, so it can mix the ring
|
||||
in). Add a vmbox case that holds `snd-dummy` open and proves the ring still
|
||||
plays.
|
||||
|
||||
## 4. S5 (poweroff) RTC wake test on the host
|
||||
|
||||
Hibernate (S4) wake is verified and is the default mode. S5 is not. The
|
||||
self-resume boot unit (`~/data/claude-scratch/wake-fw/s5_after_boot.sh`,
|
||||
never installed) was refused by the classifier. Simplest: with kuhy present,
|
||||
`sudo -n rtcwake -m no -s 240` + record the epoch, `systemctl poweroff`; kuhy
|
||||
powers on if it does not wake; then compare `uptime -s` with the epoch. If S5
|
||||
works, decide with kuhy whether to switch `pc_wake.json` to `poweroff` (covers
|
||||
a typed `shutdown`, not just the power key).
|
||||
|
||||
## 5. utils dependency upgrade — prepared, uncommitted
|
||||
|
||||
In `~/src/utils` (do NOT stage the other session's `ts_config/README.md`,
|
||||
`scripts/lib/alias-imports.mjs`, `test/alias-imports.test.js`): pylint 4.1.1
|
||||
×3, typescript-eslint 8.71.0 (ts_config/ts_core/web_ui), @eslint-react
|
||||
5.23.3, eslint-plugin-sonarjs 4.2.2, pnpm 12.8.1 (+ lockfiles,
|
||||
`ts_config/pnpm-workspace.yaml`), allowlist: typescript-eslint entry removed,
|
||||
typescript entry now `blocked_by: transitive:typescript-eslint@8.71.0`.
|
||||
Lint + tests pass in all three. `finish_auto.sh` fails at its test step with
|
||||
corepack `MODULE_NOT_FOUND .../pnpm/12.8.1/bin/pnpm.cjs` (pnpm 12.8 ships a
|
||||
native binary; corepack 0.36 can't launch it). Global pnpm 12.8.1 is now
|
||||
installed directly (`corepack disable pnpm`); find what in the gate still
|
||||
calls corepack (read `~/data/claude-scratch/wake-v2/utils-commit.log`), fix,
|
||||
commit, watch CI green.
|
||||
|
||||
## 6. Fleet repos still pinning typescript-eslint 8.70.0
|
||||
|
||||
The allowlist entry that excused them is gone, so their freshness gate turns
|
||||
red on the next run: konbini-67, iron-and-anvil, awesome-mcp-explorer,
|
||||
europe-county-map and any other repo pinning it (`grep -rl
|
||||
'"typescript-eslint": "8.70.0"' ~/src --include=package.json`). Bump each to
|
||||
8.71.0, lint + test, commit, CI green.
|
||||
|
||||
## 7. Small leftovers
|
||||
|
||||
- `~/src/todo`: `cupertino_icons` 1.0.9 → 2.0.0 — another session had
|
||||
uncommitted work there; only bump once its tree is clean.
|
||||
- Global `~/.claude/CLAUDE.md` sandbox-first note says the vmbox guest is
|
||||
BIOS-only; vmbox has `--uefi` and `vm wake` (RTC-wake emulation) since
|
||||
utils 694c013. Ask kuhy before editing that file.
|
||||
- Clear the test data left in the `wake_alarm_api36` emulator (escape for
|
||||
10-01, ringStarted, stick state) before it is ever signed into sync.
|
||||
@ -11,7 +11,7 @@
|
||||
# What it does:
|
||||
# 1. Installs wake_alarm + dependencies for /usr/bin/python
|
||||
# 2. Installs the systemd user units and enables their timers (trigger,
|
||||
# session, fallback, rtc, audit)
|
||||
# session, strip, fallback, rtc, audit)
|
||||
# 3. Installs the root pieces of the fallback alarm: the ring WAVs, the
|
||||
# ALSA ring script + system unit, the sudoers lines that let the user
|
||||
# start/stop that unit, and (pc_wake.json "hibernate" mode only) the
|
||||
@ -47,6 +47,8 @@ readonly TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service"
|
||||
readonly TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer"
|
||||
readonly SESSION_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-session.service"
|
||||
readonly SESSION_TIMER_SRC="$SCRIPT_DIR/wake-alarm-session.timer"
|
||||
readonly STRIP_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-strip.service"
|
||||
readonly STRIP_PATH_SRC="$SCRIPT_DIR/wake-alarm-strip.path"
|
||||
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||
# Enforcement v2 user units (service + timer each).
|
||||
readonly V2_UNITS=(wake-alarm-fallback wake-alarm-rtc wake-alarm-audit)
|
||||
@ -134,7 +136,8 @@ echo " wake_alarm and gatelock import under /usr/bin/python."
|
||||
echo "[2/4] Installing systemd user units..."
|
||||
mkdir -p "$SYSTEMD_USER_DIR"
|
||||
cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" \
|
||||
"$SESSION_SERVICE_SRC" "$SESSION_TIMER_SRC" "$SYSTEMD_USER_DIR/"
|
||||
"$SESSION_SERVICE_SRC" "$SESSION_TIMER_SRC" \
|
||||
"$STRIP_SERVICE_SRC" "$STRIP_PATH_SRC" "$SYSTEMD_USER_DIR/"
|
||||
for unit in "${V2_UNITS[@]}"; do
|
||||
cp "$SCRIPT_DIR/$unit.service" "$SCRIPT_DIR/$unit.timer" "$SYSTEMD_USER_DIR/"
|
||||
done
|
||||
@ -143,6 +146,8 @@ systemctl --user enable --now wake-alarm-trigger.timer
|
||||
echo " Trigger timer enabled and started (ticks once a minute)."
|
||||
systemctl --user enable --now wake-alarm-session.timer
|
||||
echo " Session timer enabled (07:00-11:00, every 2 min)."
|
||||
systemctl --user enable --now wake-alarm-strip.path
|
||||
echo " Strip path enabled (corner reminder while the morning is in progress)."
|
||||
for unit in "${V2_UNITS[@]}"; do
|
||||
systemctl --user enable --now "$unit.timer"
|
||||
done
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
/// Platform gate for the programmatic Google sign-in flow.
|
||||
///
|
||||
/// Conditional export because the answer differs per platform *and* because
|
||||
/// asking the plugin directly is unsafe: `supportsAuthenticate()` throws
|
||||
/// `UnimplementedError` where no implementation is registered -- an `Error`,
|
||||
/// not an `Exception`, so it escapes an ordinary catch and would take down
|
||||
/// the settings screen's `build()`.
|
||||
library;
|
||||
|
||||
export 'google_platform_io.dart'
|
||||
if (dart.library.js_interop) 'google_platform_web.dart';
|
||||
@ -1,10 +0,0 @@
|
||||
/// Whether the programmatic Google sign-in flow exists on this platform.
|
||||
///
|
||||
/// The io half: Android has the Credential Manager flow; Linux desktop does
|
||||
/// not, and this app's desktop build is the web one anyway.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
/// True only on Android, the one platform shipping the programmatic flow.
|
||||
bool get googleSignInSupported => Platform.isAndroid;
|
||||
@ -1,8 +0,0 @@
|
||||
/// Whether the programmatic Google sign-in flow exists on this platform.
|
||||
///
|
||||
/// The web half: Google Identity Services signs in only through its own
|
||||
/// rendered button, so `authenticate()` throws `UnimplementedError` here.
|
||||
library;
|
||||
|
||||
/// Always false: the web plugin has no programmatic sign-in.
|
||||
bool get googleSignInSupported => false;
|
||||
@ -2,9 +2,9 @@
|
||||
///
|
||||
/// Why this is its own file: `crdt_sync` is deliberately pure Dart (only
|
||||
/// `crypto` and `http`), because the same library runs on Linux desktop and
|
||||
/// headless under systemd, where `google_sign_in` does not exist. So the
|
||||
/// plugin lives here, in the app, and the library only ever receives a token
|
||||
/// string through a closure.
|
||||
/// headless under systemd, where Android's AccountManager does not exist. So
|
||||
/// the plugin call lives here, in the app, and the library only ever receives
|
||||
/// a token string through a closure.
|
||||
///
|
||||
/// What this buys: on a fresh install the sync account is reached by tapping
|
||||
/// one account-picker row instead of typing a long password on a phone
|
||||
@ -12,10 +12,7 @@
|
||||
/// credential for desktop and the Python side.
|
||||
library;
|
||||
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:wake_alarm_sync/services/google_platform.dart' as platform_gate;
|
||||
import 'package:google_account_token/google_account_token.dart' as plugin;
|
||||
|
||||
/// The account the security rules pin.
|
||||
///
|
||||
@ -50,73 +47,26 @@ const kServerClientId =
|
||||
|
||||
/// Whether this platform can sign in programmatically.
|
||||
///
|
||||
/// False on web, where Google Identity Services only signs in through its own
|
||||
/// rendered button: `authenticate()` throws `UnimplementedError` there. The
|
||||
/// desktop build of this app *is* the web build, so without this check the
|
||||
/// "Sign in with Google" button would crash the settings screen on desktop
|
||||
/// rather than falling back; see `google_platform.dart` for why this is a
|
||||
/// platform check rather than a question asked of the plugin.
|
||||
/// True only on Android (the plugin's own gate): the desktop build of this
|
||||
/// app *is* the web build, and neither has an AccountManager, so without this
|
||||
/// check the "Sign in with Google" button would show a flow that can never
|
||||
/// succeed instead of falling back to the password path.
|
||||
///
|
||||
/// Also false when no client id is compiled in: a button that cannot possibly
|
||||
/// succeed is worse than no button, and without this check an unconfigured
|
||||
/// build reports "cancelled" and sends you debugging the OAuth console for a
|
||||
/// problem that is really a missing constant.
|
||||
/// succeed is worse than no button.
|
||||
bool get googleSignInSupported =>
|
||||
platform_gate.googleSignInSupported && kServerClientId.isNotEmpty;
|
||||
plugin.googleAccountTokenSupported && kServerClientId.isNotEmpty;
|
||||
|
||||
/// Returns a Google ID token for the signed-in account, or null.
|
||||
///
|
||||
/// Null rather than throwing when the user dismisses the picker: cancelling is
|
||||
/// an ordinary outcome, and the caller falls back to the password path.
|
||||
///
|
||||
/// [signInFn] and [serverClientId] exist for tests, which cannot reach the
|
||||
/// platform channel the plugin uses.
|
||||
/// [signInFn] and [serverClientId] exist for tests that bypass the plugin.
|
||||
Future<String?> googleIdToken({
|
||||
Future<String?> Function()? signInFn,
|
||||
String serverClientId = kServerClientId,
|
||||
}) async {
|
||||
if (signInFn != null) return await signInFn();
|
||||
if (serverClientId.isEmpty) {
|
||||
// A build without --dart-define=GOOGLE_SERVER_CLIENT_ID would otherwise
|
||||
// fail inside the plugin with a far less obvious message.
|
||||
log(
|
||||
'Google sign-in unavailable: GOOGLE_SERVER_CLIENT_ID was not set at '
|
||||
'build time; falling back to the password path',
|
||||
level: 900,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// The plugin reaches the OS account picker through a platform channel,
|
||||
// which `flutter test` has no binding for -- the same reason main.dart and
|
||||
// firebase_backend.dart's keystore adapters are excluded. Everything above
|
||||
// this point is pure Dart and is covered.
|
||||
// coverage:ignore-start
|
||||
try {
|
||||
await GoogleSignIn.instance.initialize(serverClientId: serverClientId);
|
||||
if (!GoogleSignIn.instance.supportsAuthenticate()) {
|
||||
// Web/desktop: sign-in must go through the rendered GIS button, which
|
||||
// this app does not host yet. Fall back rather than throwing.
|
||||
log(
|
||||
'Google sign-in is not available on this platform; falling back to '
|
||||
'the password path',
|
||||
level: 900,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final account = await GoogleSignIn.instance.authenticate();
|
||||
return account.authentication.idToken;
|
||||
} on GoogleSignInException catch (error, stackTrace) {
|
||||
// Includes the user simply dismissing the picker, which is not an error
|
||||
// worth surfacing -- but log it, because a *configuration* failure
|
||||
// (unregistered SHA-1, wrong client id) arrives through the same path and
|
||||
// is otherwise indistinguishable from "the user changed their mind".
|
||||
log(
|
||||
'Google sign-in did not complete',
|
||||
level: 900,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// coverage:ignore-end
|
||||
return await plugin.googleIdToken(serverClientId: serverClientId);
|
||||
}
|
||||
|
||||
@ -5,10 +5,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: android_file_picker
|
||||
sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8"
|
||||
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.3"
|
||||
android_intent_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -101,11 +101,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_dart
|
||||
ref: "crdt_sync_dart-v0.11.3"
|
||||
resolved-ref: a2e7334dfec5131170a084510d70361d53dc40bc
|
||||
ref: "crdt_sync_dart-v0.12.0"
|
||||
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.11.3"
|
||||
version: "0.12.0"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -134,11 +134,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: data_transfer_flutter
|
||||
ref: "data_transfer_flutter-v0.2.0"
|
||||
resolved-ref: bbb7f0f0c0cd891518418472955c8a2ac6cbca4d
|
||||
ref: "data_transfer_flutter-v0.2.1"
|
||||
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.2.0"
|
||||
version: "0.2.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -183,18 +183,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592"
|
||||
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.3.1"
|
||||
version: "13.3.2"
|
||||
file_picker_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_darwin
|
||||
sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54
|
||||
sha256: daeb0219f6279c73eb9c63ae9a566beef04ae13d59b8feba0be2a7f3b258d3d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2"
|
||||
file_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -371,54 +371,15 @@ packages:
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.3.2"
|
||||
google_identity_services_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_identity_services_web
|
||||
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+1"
|
||||
google_sign_in:
|
||||
google_account_token:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_sign_in
|
||||
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
google_sign_in_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_android
|
||||
sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.16"
|
||||
google_sign_in_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_ios
|
||||
sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
google_sign_in_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_platform_interface
|
||||
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
google_sign_in_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_web
|
||||
sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
path: google_account_token
|
||||
ref: "google_account_token-v0.1.0"
|
||||
resolved-ref: db70ba204d124c6eadf9b8808cd3d3b3e70363d1
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -772,11 +733,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: sync_settings_ui
|
||||
ref: "sync_settings_ui-v0.5.2"
|
||||
resolved-ref: "648bde44b1756485d95ca3c3a8714cd2015d5be4"
|
||||
ref: "sync_settings_ui-v0.6.1"
|
||||
resolved-ref: ebedbf4b27fa332f7b8e6d80117d1c409bf3a7ad
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.5.2"
|
||||
version: "0.6.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -12,7 +12,7 @@ dependencies:
|
||||
crdt_sync:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.11.3
|
||||
ref: crdt_sync_dart-v0.12.0
|
||||
path: crdt_sync_dart
|
||||
cupertino_icons: 2.0.0
|
||||
|
||||
@ -20,7 +20,7 @@ dependencies:
|
||||
data_transfer_flutter:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: data_transfer_flutter-v0.2.0
|
||||
ref: data_transfer_flutter-v0.2.1
|
||||
path: data_transfer_flutter
|
||||
flutter:
|
||||
sdk: flutter
|
||||
@ -33,7 +33,11 @@ dependencies:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: github_device_auth-v0.3.2
|
||||
path: github_device_auth
|
||||
google_sign_in: 7.2.0
|
||||
google_account_token:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: google_account_token-v0.1.0
|
||||
path: google_account_token
|
||||
http: 1.6.0
|
||||
path: 1.9.1
|
||||
path_provider: 2.1.6
|
||||
@ -45,7 +49,7 @@ dependencies:
|
||||
sync_settings_ui:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: sync_settings_ui-v0.5.2
|
||||
ref: sync_settings_ui-v0.6.1
|
||||
path: sync_settings_ui
|
||||
url_launcher: 6.3.3
|
||||
uuid: 4.6.0
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
|
||||
|
||||
void main() {
|
||||
test('reports whether the programmatic Google flow exists here', () {
|
||||
// Android is the only platform shipping it. Asked as a platform question
|
||||
// rather than of the plugin, because GoogleSignIn.supportsAuthenticate()
|
||||
// itself throws UnimplementedError where no implementation is registered
|
||||
// -- an Error, not an Exception, so it would escape an ordinary catch and
|
||||
// take down the settings screen's build(). The test host is Linux, so
|
||||
// this is false here and the password path stays visible.
|
||||
expect(googleSignInSupported, isFalse);
|
||||
});
|
||||
}
|
||||
@ -1,15 +1,24 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
|
||||
|
||||
const _channel = MethodChannel('dev.kuhy/google_account_token');
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
tearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_channel, null);
|
||||
});
|
||||
|
||||
group('googleIdToken', () {
|
||||
test('returns the token the sign-in function yields', () async {
|
||||
// The injected path: proves the closure is what feeds crdt_sync, and
|
||||
// that no platform channel is touched to get there.
|
||||
expect(
|
||||
await googleIdToken(signInFn: () async => 'a-token'),
|
||||
'a-token',
|
||||
);
|
||||
expect(await googleIdToken(signInFn: () async => 'a-token'), 'a-token');
|
||||
});
|
||||
|
||||
test('passes a null cancellation straight through', () async {
|
||||
@ -19,12 +28,24 @@ void main() {
|
||||
});
|
||||
|
||||
test('reports "not configured" when the client id is missing', () async {
|
||||
// Passed explicitly rather than relying on the const: an app whose
|
||||
// client id is filled in would otherwise reach the real plugin here.
|
||||
// The behaviour under test is the empty-id fallback itself.
|
||||
// An empty client id must fall back rather than reach the OS chooser.
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
expect(await googleIdToken(serverClientId: ''), isNull);
|
||||
});
|
||||
|
||||
test('asks the account-token plugin for the Web client id', () async {
|
||||
// The real path: the plugin's channel gets the pinned Web client id.
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
Object? sent;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_channel, (call) async {
|
||||
sent = call.arguments;
|
||||
return 'plugin-token';
|
||||
});
|
||||
expect(await googleIdToken(), 'plugin-token');
|
||||
expect(sent, {'serverClientId': kServerClientId});
|
||||
});
|
||||
|
||||
test('pins the uid the security rules expect', () {
|
||||
// If this ever drifts from database.rules.json, a Google sign-in would
|
||||
// be accepted here and then denied every read and write.
|
||||
|
||||
19
phone_app/test/google_sign_in_supported_test.dart
Normal file
19
phone_app/test/google_sign_in_supported_test.dart
Normal file
@ -0,0 +1,19 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
test('the one-tap button is hidden off Android', () {
|
||||
// flutter_test defaults to Android, so pin Linux (the desktop build): no
|
||||
// AccountManager, so the password path stays the only option.
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
expect(googleSignInSupported, isFalse);
|
||||
});
|
||||
|
||||
test('the one-tap button shows on Android', () {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
expect(googleSignInSupported, isTrue);
|
||||
});
|
||||
}
|
||||
24
phone_app/test/services/session_legs_fixture_test.dart
Normal file
24
phone_app/test/services/session_legs_fixture_test.dart
Normal file
@ -0,0 +1,24 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wake_alarm_sync/services/morning_session.dart';
|
||||
|
||||
/// The PC reads leg names (the keys of the published `passed` map) and labels
|
||||
/// (its `next_step`) from the same literal: the Python half asserts it in
|
||||
/// `wake_alarm/tests/test_session_legs.py`. Neither side's own round-trip can
|
||||
/// catch a rename on the other.
|
||||
void main() {
|
||||
test('SessionLeg names and labels equal the shared fixture', () {
|
||||
final raw = File(
|
||||
'../wake_alarm/tests/fixtures/session_legs.json',
|
||||
).readAsStringSync();
|
||||
final legs = (jsonDecode(raw) as Map<String, Object?>)['legs']! as List;
|
||||
expect(
|
||||
<(String, String)>[for (final l in SessionLeg.values) (l.name, l.label)],
|
||||
<(String, String)>[
|
||||
for (final l in legs) (l['name'] as String, l['label'] as String),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -1,202 +0,0 @@
|
||||
> **This file is a ready-to-use prompt.** Open this repo and say
|
||||
> "do dopamine-ux-wake-alarm". It is self-contained -- it needs no context from
|
||||
> any other session.
|
||||
>
|
||||
> Canonical copy: `~/src/utils/dopamine-ux/09-wake-alarm.md` (source of truth; this is a
|
||||
> distributed copy). Running order and cross-prompt rules: `~/src/utils/dopamine-ux/00-INDEX.md`.
|
||||
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
|
||||
> as of that date; **anchor on the symbol names, not the line numbers.**
|
||||
|
||||
## ⛔ WHEN YOU FINISH THIS PROMPT
|
||||
|
||||
Do **both**, in the same session that completes the work:
|
||||
|
||||
1. **Delete this file** and commit that deletion together with the
|
||||
implementation:
|
||||
```bash
|
||||
git rm prompts/dopamine-ux-wake-alarm.md
|
||||
```
|
||||
Do not leave a finished prompt lying in the repo -- a stale prompt is
|
||||
indistinguishable from a pending one, and the next session will re-run it.
|
||||
|
||||
2. **Log completion in the canonical index**, in `~/src/utils` (a *different* repo,
|
||||
so it needs its own commit):
|
||||
```bash
|
||||
cd ~/src/utils
|
||||
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
|
||||
# | 09-wake-alarm.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
|
||||
git add dopamine-ux/00-INDEX.md
|
||||
git commit -m "dopamine-ux: mark 09-wake-alarm.md done"
|
||||
git push
|
||||
```
|
||||
|
||||
If you finish only part of the prompt, do **neither** -- instead edit this file
|
||||
to strike out what is done and state what remains, so the next session starts
|
||||
from the true state. Partial completion must never be recorded as DONE.
|
||||
|
||||
---
|
||||
|
||||
Add motion and haptics to the wake-alarm phone app
|
||||
|
||||
## findings (2, ranked by value over effort)
|
||||
|
||||
1. **Setting an alarm gives no confirmation or haptic** — and since the real
|
||||
alarm lives in another app entirely, confirming the handoff is the only
|
||||
reassurance this app can offer.
|
||||
2. **Zero motion, and the thinnest local theme in the fleet** — it lacks
|
||||
`AppSpacing`/`AppRadius`/`AppTextSize`, so widgets copied from sibling apps
|
||||
will not compile here.
|
||||
|
||||
## what
|
||||
|
||||
`~/src/wake-alarm/phone_app` is the smallest surface in this project: 12 Dart files,
|
||||
a sync companion that sets a time, syncs it, and hands off to the system clock
|
||||
app. It has **zero motion, zero haptics, zero audio, zero progress feedback** —
|
||||
verified 2026-08-16. Its settings screen is a 56-line `StatelessWidget` with two
|
||||
`ListTile`s and no state at all.
|
||||
|
||||
Bring it up to the fleet's baseline: a confirmation the alarm was actually set,
|
||||
motion tokens on its transitions, and a haptic on the one action that matters.
|
||||
|
||||
**This app is deliberately last and deliberately small.** It also carries the
|
||||
project's sharpest hazard — see `must not` about alarm audio.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/wake-alarm`. Flutter app: `~/src/wake-alarm/phone_app` (**underscore**, not
|
||||
`phone-app`). The repo root is a Python package; the Flutter app is only the
|
||||
`phone_app/` subdirectory.
|
||||
|
||||
Primary:
|
||||
- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` ~:86-96 builds an
|
||||
`AndroidIntent(action: 'android.intent.action.SET_ALARM', arguments: {HOUR,
|
||||
MINUTES, SKIP_UI: false, MESSAGE: 'Wake Alarm'})` via `android_intent_plus`.
|
||||
The `CircularProgressIndicator` is at ~:189. The only `Duration` in the whole
|
||||
app is `.timeout(const Duration(seconds: 3))` ~:95 — **an intent timeout, not
|
||||
animation. Do not tokenise it.**
|
||||
- `phone_app/lib/screens/settings_screen.dart` — 56 lines, `StatelessWidget`,
|
||||
`ListView` ~:21-53, `MaterialPageRoute` ~:30, :49.
|
||||
- `phone_app/lib/services/sync_settings.dart` — holds **only** the GitHub token
|
||||
in the keystore (`FlutterSecureStorage`, `_secureTokenKey = 'github_token'`
|
||||
~:31, `load()` ~:36, `save()` ~:61). There is no general app-preferences class.
|
||||
- `phone_app/lib/ui/theme.dart` — local hand-copy, **no `design_system`
|
||||
dependency**. `buildAppTheme()` ~:12, `AppStatusColors` ~:54. ⚠️ Unlike the
|
||||
other apps it does **not** declare `AppSpacing`/`AppRadius`/`AppTextSize`, so a
|
||||
widget copied from diet-guard or workout_app **will not compile here**. Wired at
|
||||
`phone_app/lib/main.dart` ~:18.
|
||||
|
||||
## must
|
||||
|
||||
**Step 1 — motion + haptics (no new dependency).**
|
||||
|
||||
- Add `HapticFeedback.mediumImpact()` when the alarm is successfully set — the
|
||||
single most important action in the app. Fire it on the tap, not after the
|
||||
intent round-trips. `HapticFeedback` is in `package:flutter/services.dart`; add
|
||||
`<uses-permission android:name="android.permission.VIBRATE"/>` to
|
||||
`phone_app/android/app/src/main/AndroidManifest.xml` if absent.
|
||||
- Give setting the alarm a **clear confirmation**: which time was set, and that
|
||||
the handoff to the system clock succeeded. Today the user gets a `_status`
|
||||
string and a spinner. Since the actual alarm lives in another app, confirming
|
||||
the handoff is the only reassurance this app can offer — make it unambiguous.
|
||||
- Transcribe the motion tokens from prompt 01 into `phone_app/lib/ui/theme.dart`,
|
||||
matching the existing hand-copy convention. Add `AppSpacing`/`AppRadius`/
|
||||
`AppTextSize` **only if** you need them; otherwise leave the file's shape alone.
|
||||
- Apply motion tokens to the two `MaterialPageRoute` transitions.
|
||||
- Honour `MediaQuery.of(context).disableAnimations`.
|
||||
|
||||
**Step 2 — sound (optional here; consider skipping).**
|
||||
|
||||
- A UI confirmation cue would need an audio dependency, an `assets:` block, a
|
||||
settings toggle, **and** a preferences class that does not exist yet (see
|
||||
below). For an app whose entire job is to hand off to another app, that is a
|
||||
poor value ratio.
|
||||
- **Recommendation: skip sound in this app** and say so explicitly in the session
|
||||
summary. If you do add it, it needs the full opt-out toggle like everywhere
|
||||
else — and the toggle work below.
|
||||
- To add any toggle here you must do three things the other apps do not require:
|
||||
(a) convert `SettingsScreen` from `StatelessWidget` to `StatefulWidget`,
|
||||
(b) create a preferences class — there is no `AppSettings` equivalent; model it
|
||||
on `~/src/todo/lib/data/app_settings.dart`, and note `shared_preferences: ^2.3.0`
|
||||
is **already a dependency**, (c) add the `SwitchListTile` to the `ListView`.
|
||||
|
||||
**Both steps:**
|
||||
|
||||
- must not: **touch anything under `~/src/wake-alarm/wake_alarm/`.** That is the
|
||||
desktop Python daemon that produces the real alarm noise — sine-tone WAVs via
|
||||
`paplay` → `aplay` → `speaker-test` (`_audio.py`, `_play_on_all_sinks` ~:218,
|
||||
pcspkr evdev fallback `_beep_pcspkr` ~:81), escalating 440Hz → 1000Hz → loud in
|
||||
the loop at `_alarm.py` ~:393-407, with sink volume save/restore in `_sinks.py`.
|
||||
**Never route a UI-sound opt-out through any of it.** A toggle that silences a
|
||||
wake-up alarm is a bug that makes someone late for work.
|
||||
- The two systems never share a process, package, or file — `phone_app` has no
|
||||
audio engine at all — so a UI sound layer here **structurally cannot** touch
|
||||
alarm playback. Keep it that way; do not build a bridge between them.
|
||||
- must not: change the alarm-setting intent, its arguments, or the 3s timeout.
|
||||
Feedback wraps that call; it does not modify it.
|
||||
- must not: add a streak, a counter, or any reward for setting an alarm. Waking
|
||||
up is not a metric this app owns, and rewarding "alarms set" rewards the wrong
|
||||
act entirely.
|
||||
- must not: copy a widget from another app without checking it only uses symbols
|
||||
this app's thinner theme actually declares.
|
||||
|
||||
## done
|
||||
|
||||
1. Setting an alarm produces a haptic and an unambiguous confirmation naming the
|
||||
time that was set.
|
||||
2. Page transitions use the shared motion tokens; no inline animation `Duration`
|
||||
remains (the 3s intent timeout stays).
|
||||
3. `cd ~/src/wake-alarm/phone_app && flutter analyze` is clean.
|
||||
4. `cd ~/src/wake-alarm/phone_app && flutter test` passes.
|
||||
5. With OS "remove animations" enabled, the app works and durations are zero.
|
||||
6. `git diff` shows **no changes under `~/src/wake-alarm/wake_alarm/`**.
|
||||
7. The summary states whether sound was added or deliberately skipped.
|
||||
|
||||
## verify
|
||||
|
||||
**On the phone.**
|
||||
|
||||
```
|
||||
adb devices # confirm 23181JEGR08034
|
||||
cd ~/src/wake-alarm/phone_app
|
||||
flutter build apk --release
|
||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
Never `flutter install`, never `adb uninstall`, never `pm clear` — the app stores
|
||||
a GitHub token in the keystore and wiping it means re-authenticating.
|
||||
|
||||
Then set a real alarm on the device: confirm the haptic fired (state it as a
|
||||
physical observation), the confirmation named the right time, and **the system
|
||||
Clock app actually received the alarm**. The last one is the real test — this
|
||||
app's job is the handoff, so verify the alarm exists in the Clock app, not just
|
||||
that this screen said so.
|
||||
|
||||
Do **not** test by letting an alarm actually fire unless kuhy asks — that
|
||||
involves the desktop daemon and real noise.
|
||||
|
||||
## read first
|
||||
|
||||
- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` in full,
|
||||
including the timeout and the `_status` string handling.
|
||||
- `phone_app/lib/ui/theme.dart` — note what it does **not** declare before
|
||||
writing any widget.
|
||||
- `~/src/todo/lib/data/app_settings.dart` — the preferences-class pattern, only if
|
||||
you decide to do step 2.
|
||||
- `~/src/utils/unified-design-system/motion.md` — vocabulary from prompt 01.
|
||||
**Prompt 01 must have run first.**
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- **The Flutter app produces no audio whatsoever.** No audio package, no
|
||||
`assets:` block; `assets/` contains only launcher icons. It fires an Android
|
||||
`SET_ALARM` intent and the **system Clock app** rings. The desktop Python
|
||||
daemon is an entirely separate program on a different machine.
|
||||
- The repo root (`~/src/wake-alarm/wake_alarm/`, `wake_alarm.egg-info`) is the Python
|
||||
package. Only `phone_app/` is Flutter.
|
||||
- This app has **no `design_system` dependency** and the thinnest local theme in
|
||||
the fleet. Adding the dependency is out of scope here (see prompt 07 for what
|
||||
that migration looks like when done properly).
|
||||
- No streaks, no charts, no progress, no completion counts exist — and none
|
||||
should be added. This is a two-button utility.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -8,7 +8,7 @@ version = "1.0.0"
|
||||
description = "Trigger that starts the morning routine at the phone's alarm time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.0#subdirectory=gatelock",
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock",
|
||||
# Firebase transport for the alarm read. _sync.py imports it lazily and
|
||||
# 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.
|
||||
@ -16,7 +16,7 @@ dependencies = [
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
|
||||
# Sick days stand the PC's enforcement down (_sick.py). Imported behind
|
||||
# a guard: missing means "not sick", never a crashed tick.
|
||||
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays",
|
||||
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays",
|
||||
]
|
||||
|
||||
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python
|
||||
|
||||
@ -4,10 +4,10 @@
|
||||
# it is missing — but it is required here for mypy/pylint/tests.
|
||||
bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.17.0
|
||||
coverage==7.17.2
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.0#subdirectory=gatelock
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||
mypy==2.4.1
|
||||
pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
@ -16,5 +16,5 @@ pytest-cov==7.1.0
|
||||
pytest-randomly==5.0.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-xdist==3.8.0
|
||||
ruff==0.17.3
|
||||
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||
ruff==0.17.4
|
||||
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays
|
||||
|
||||
12
scripts/todo_hooks.sh
Executable file
12
scripts/todo_hooks.sh
Executable file
@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Shim: forward to the shared todo git hooks in ~/src/utils (silent if absent).
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED="${UTILS_ROOT:-$HOME/src/utils}/scripts/todo_hooks.sh"
|
||||
|
||||
main() {
|
||||
[[ -f "$SHARED" ]] || exit 0
|
||||
exec bash "$SHARED" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -86,10 +86,14 @@ remove_trigger() {
|
||||
echo " Disabling the trigger timer"
|
||||
systemctl --user disable --now wake-alarm-trigger.timer >/dev/null 2>&1 || true
|
||||
systemctl --user disable --now wake-alarm-session.timer >/dev/null 2>&1 || true
|
||||
systemctl --user disable --now wake-alarm-strip.path >/dev/null 2>&1 || true
|
||||
systemctl --user stop wake-alarm-strip.service >/dev/null 2>&1 || true
|
||||
rm -f "$SYSTEMD_USER_DIR/wake-alarm-trigger.service" \
|
||||
"$SYSTEMD_USER_DIR/wake-alarm-trigger.timer" \
|
||||
"$SYSTEMD_USER_DIR/wake-alarm-session.service" \
|
||||
"$SYSTEMD_USER_DIR/wake-alarm-session.timer"
|
||||
"$SYSTEMD_USER_DIR/wake-alarm-session.timer" \
|
||||
"$SYSTEMD_USER_DIR/wake-alarm-strip.service" \
|
||||
"$SYSTEMD_USER_DIR/wake-alarm-strip.path"
|
||||
}
|
||||
|
||||
remove_enforcement() {
|
||||
|
||||
@ -11,8 +11,9 @@ OnCalendar=*-*-* 05..06:00/10:00
|
||||
OnCalendar=*-*-* 07..10:00/2:00
|
||||
OnCalendar=*-*-* 11:00:00
|
||||
AccuracySec=1s
|
||||
# A PC booted mid-morning refreshes at once; the consumers wait up to 30 s
|
||||
# for exactly this catch-up before treating the file as stale.
|
||||
# A PC booted mid-morning refreshes at once, retrying in-process every 5 s
|
||||
# for 150 s (no DNS yet at boot); the consumers wait up to 180 s for exactly
|
||||
# this catch-up before treating the file as stale.
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
|
||||
14
wake-alarm-strip.path
Normal file
14
wake-alarm-strip.path
Normal file
@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Show the morning-session strip whenever the signed session file is rewritten
|
||||
Documentation=https://github.com/kuhyx/wake-alarm
|
||||
|
||||
[Path]
|
||||
# wake-alarm-session.timer rewrites this every 2 min through the morning (and
|
||||
# the strip itself asks for a refresh every 30 s while it is up). Starting a
|
||||
# service that is already running is a no-op, so the rewrites cost nothing;
|
||||
# once the session is over the strip exits 0 at once.
|
||||
PathChanged=%h/.local/state/wake_alarm/morning_session.json
|
||||
Unit=wake-alarm-strip.service
|
||||
|
||||
[Install]
|
||||
WantedBy=paths.target
|
||||
14
wake-alarm-strip.service
Normal file
14
wake-alarm-strip.service
Normal file
@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Wake Alarm morning-session strip (top-right corner reminder)
|
||||
Documentation=https://github.com/kuhyx/wake-alarm
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Same display handling as leetcode-guard.service, the other Tk user unit:
|
||||
# the session's own X server, no env fallbacks invented here.
|
||||
Environment=DISPLAY=:0
|
||||
Environment=XAUTHORITY=%h/.Xauthority
|
||||
# Exits 0 by itself when the morning session is not in progress (the normal
|
||||
# case for most path triggers) and again the moment it ends. Exit 1 means no
|
||||
# display or no live output; the next file rewrite tries again.
|
||||
ExecStart=/usr/bin/python -m wake_alarm._session_strip
|
||||
@ -156,6 +156,13 @@ SESSION_COMPLETED_UNTIL: tuple[int, int] = (11, 0)
|
||||
# PC). A phone switched off at the alarm locks this long after it.
|
||||
SESSION_NONE_GRACE: timedelta = timedelta(minutes=15)
|
||||
|
||||
# A PC booted mid-morning often has no DNS for its first seconds (2026-11-07:
|
||||
# one try at 07:48:43, exit 1, next timer tick at 07:50). ``--refresh`` keeps
|
||||
# trying in-process this long, every REFRESH_POLL_SECONDS, before giving up.
|
||||
# Consumers (gatelock.morning_session) wait up to 180 s, so this stays under.
|
||||
REFRESH_RETRY_SECONDS: int = 150
|
||||
REFRESH_POLL_SECONDS: int = 5
|
||||
|
||||
|
||||
# Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days
|
||||
# themselves are freedays.WORKDAYS, the one set every gate app reads.
|
||||
|
||||
@ -31,10 +31,17 @@ from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
||||
|
||||
from wake_alarm._constants import FALLBACK_ALARM_HOUR, MORNING_SESSION_FILE
|
||||
from wake_alarm._constants import (
|
||||
FALLBACK_ALARM_HOUR,
|
||||
MORNING_SESSION_FILE,
|
||||
REFRESH_POLL_SECONDS,
|
||||
REFRESH_RETRY_SECONDS,
|
||||
)
|
||||
from wake_alarm._enforcement_payload import enforcement_view
|
||||
from wake_alarm._escapes import note_escapes
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
@ -45,6 +52,9 @@ from wake_alarm._sync_transport import fetch_firebase_logs
|
||||
from wake_alarm._workday_penalty import cancel_for_sick_day as _cancel_for_sick_day
|
||||
from wake_alarm._workday_penalty import maybe_apply as _maybe_apply_workday_penalty
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -81,18 +91,49 @@ def read_entry() -> dict[str, object] | None:
|
||||
return entry
|
||||
|
||||
|
||||
def refresh(now: datetime) -> dict[str, object] | None:
|
||||
def _fetch_with_retry(
|
||||
sleep: Callable[[float], None], clock: Callable[[], float]
|
||||
) -> list[tuple[str, str]] | None:
|
||||
"""``fetch_firebase_logs`` retried every poll interval within the budget.
|
||||
|
||||
A PC booted mid-morning usually has no DNS for its first seconds, and the
|
||||
next timer tick is minutes away while consumers are already waiting on the
|
||||
file. None only once ``REFRESH_RETRY_SECONDS`` have passed.
|
||||
"""
|
||||
started = clock()
|
||||
while True:
|
||||
published = fetch_firebase_logs()
|
||||
if published is not None:
|
||||
return published
|
||||
waited = clock() - started
|
||||
if waited + REFRESH_POLL_SECONDS > REFRESH_RETRY_SECONDS:
|
||||
_logger.warning(
|
||||
"Morning session not refreshed: backend unreadable for %d s "
|
||||
"(retried every %d s)",
|
||||
REFRESH_RETRY_SECONDS,
|
||||
REFRESH_POLL_SECONDS,
|
||||
)
|
||||
return None
|
||||
sleep(REFRESH_POLL_SECONDS)
|
||||
|
||||
|
||||
def refresh(
|
||||
now: datetime,
|
||||
*,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> dict[str, object] | None:
|
||||
"""Fetch, select, and rewrite the file; None when the backend was unreadable.
|
||||
|
||||
An unreadable backend leaves the file alone: whatever it says is still
|
||||
the newest thing known, and a consumer judges freshness by its date. A
|
||||
penalty issued on a day since marked sick is retracted first, from the
|
||||
local pool alone, so it does not wait on Firebase.
|
||||
An unreadable backend is retried for ``REFRESH_RETRY_SECONDS``, then
|
||||
leaves the file alone: whatever it says is still the newest thing known,
|
||||
and a consumer judges freshness by its date. A penalty issued on a day
|
||||
since marked sick is retracted first, once, from the local pool alone, so
|
||||
it does not wait on Firebase. ``sleep`` and ``clock`` are seams for tests.
|
||||
"""
|
||||
_cancel_for_sick_day()
|
||||
published = fetch_firebase_logs()
|
||||
published = _fetch_with_retry(sleep, clock)
|
||||
if published is None:
|
||||
_logger.info("Morning session not refreshed: backend unreadable")
|
||||
return None
|
||||
today = now.date().isoformat()
|
||||
record = newest_session(published)
|
||||
|
||||
30
wake_alarm/_session_legs.py
Normal file
30
wake_alarm/_session_legs.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""The morning session's legs, in order, as the phone names and labels them.
|
||||
|
||||
Mirrors ``SessionLeg`` and ``SessionLegSlot.label`` in
|
||||
``phone_app/lib/services/morning_session.dart``. A leg's *name* is the key of
|
||||
the phone's ``passed`` map; its *label* is what the PC's ``next_step`` shows.
|
||||
``tests/fixtures/session_legs.json`` is asserted by a Python and a Dart test,
|
||||
so a rename on one side fails the other's build.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Collection
|
||||
|
||||
LEGS: tuple[tuple[str, str], ...] = (
|
||||
("bathroomIn", "Tap the bathroom tag"),
|
||||
("showerDone", "Shower, then tap the bathroom tag again"),
|
||||
("dressed", "Get dressed — tap the wardrobe tag"),
|
||||
("atDesk", "Sit down — tap the desk tag"),
|
||||
)
|
||||
|
||||
|
||||
def next_step(passed: Collection[str]) -> str | None:
|
||||
"""Label of the first leg (in order) not in ``passed``; None when all are."""
|
||||
for name, label in LEGS:
|
||||
if name not in passed:
|
||||
return label
|
||||
return None
|
||||
@ -52,6 +52,8 @@ class SessionRecord:
|
||||
re_rings: int
|
||||
device_id: str
|
||||
published_at: float | None
|
||||
# Names (``_session_legs.LEGS``) of the legs the phone has passed.
|
||||
passed_legs: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
@ -73,6 +75,19 @@ def _float_or_none(value: object) -> float | None:
|
||||
return float(value)
|
||||
|
||||
|
||||
def _passed_legs(value: object) -> tuple[str, ...]:
|
||||
"""Leg names in the phone's ``passed`` map that carry a pass timestamp."""
|
||||
if not isinstance(value, dict):
|
||||
return ()
|
||||
return tuple(
|
||||
name
|
||||
for name, entry in value.items()
|
||||
if isinstance(name, str)
|
||||
and isinstance(entry, dict)
|
||||
and isinstance(entry.get("at"), str)
|
||||
)
|
||||
|
||||
|
||||
def record_from_json(
|
||||
value: object,
|
||||
*,
|
||||
@ -106,6 +121,7 @@ def record_from_json(
|
||||
re_rings=_int_or_none(value.get("re_rings")) or 0,
|
||||
device_id=device_id,
|
||||
published_at=published_at,
|
||||
passed_legs=_passed_legs(value.get("passed")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ from wake_alarm._constants import (
|
||||
SESSION_PROVISIONAL_CAP,
|
||||
SESSION_PROVISIONAL_GRACE,
|
||||
)
|
||||
from wake_alarm._session_legs import next_step
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wake_alarm._session_payload import SessionRecord
|
||||
@ -113,6 +114,7 @@ def to_entry(
|
||||
"device_id": "",
|
||||
"published_at": None,
|
||||
"refreshed_at": refreshed_at,
|
||||
"next_step": None,
|
||||
}
|
||||
if alarm is not None:
|
||||
entry["exempt_until"] = (_at(today, alarm) + SESSION_NONE_GRACE).isoformat()
|
||||
@ -128,6 +130,10 @@ def to_entry(
|
||||
"device_id": record.device_id,
|
||||
"published_at": record.published_at,
|
||||
"refreshed_at": refreshed_at,
|
||||
# Signed with the rest: the HMAC covers every key of the entry.
|
||||
"next_step": (
|
||||
next_step(record.passed_legs) if record.outcome == "in_progress" else None
|
||||
),
|
||||
}
|
||||
until = exempt_until(record)
|
||||
if until is not None:
|
||||
|
||||
247
wake_alarm/_session_strip.py
Normal file
247
wake_alarm/_session_strip.py
Normal file
@ -0,0 +1,247 @@
|
||||
"""A corner strip while the morning session is started but not finished.
|
||||
|
||||
wake-alarm's signed ``morning_session.json`` makes the lockers stand down
|
||||
while the morning is live. The screen simply clears, though, and kuhy still
|
||||
owes the rest of the routine: the locks return at ``exempt_until`` out of
|
||||
nowhere. This strip is the reminder: a small always-on-top box in the
|
||||
top-right corner of the primary output (``gatelock.corner_strip``), saying the
|
||||
session is not finished, what the phone's next step is, and when locks return.
|
||||
|
||||
python -m wake_alarm._session_strip
|
||||
|
||||
``wake-alarm-strip.path`` starts it whenever the file is rewritten (every two
|
||||
minutes through the morning); starting a running service is a no-op. It shows
|
||||
only while the entry is *signed, dated today, in_progress, and now <
|
||||
exempt_until*, re-reading the file every second, and exits 0 the moment that
|
||||
stops being true. While visible it also asks systemd every 30 s to run
|
||||
``wake-alarm-session.service`` so ``next_step`` follows the phone instead of
|
||||
the two-minute timer.
|
||||
|
||||
Fail modes, out loud: nothing to show is exit 0 (normal, logged at info); no
|
||||
display or no live output is a warning and exit 1, so the unit shows failed;
|
||||
a missing, unreadable or tampered file shows nothing (the lockers do not
|
||||
honour it either) and warns once per distinct problem, not once a second.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import LockConfig, enumerate_outputs
|
||||
from gatelock.corner_strip import StripContent, build_corner_strip, corner_rect
|
||||
from gatelock.log_integrity import verify_entry_hmac
|
||||
|
||||
from wake_alarm._constants import MORNING_SESSION_FILE
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from subprocess import Popen
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
TITLE = "Morning session not finished"
|
||||
REFRESH_EVERY_SECONDS = 30.0
|
||||
REFRESH_COMMAND = [
|
||||
"systemctl",
|
||||
"--user",
|
||||
"start",
|
||||
"--no-block",
|
||||
"wake-alarm-session.service",
|
||||
]
|
||||
|
||||
|
||||
class _Once:
|
||||
"""Warns about a problem once, until a clean read clears it.
|
||||
|
||||
The strip reads the file every second; a file that stays broken for an
|
||||
hour must be one journal line, not 3600.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.last: str | None = None
|
||||
|
||||
def warn(self, message: str) -> None:
|
||||
"""Log ``message`` at warning level unless it was just logged."""
|
||||
if message != self.last:
|
||||
_logger.warning(message)
|
||||
self.last = message
|
||||
|
||||
def clear(self) -> None:
|
||||
"""A clean read happened: the next problem is news again."""
|
||||
self.last = None
|
||||
|
||||
|
||||
_once = _Once()
|
||||
|
||||
|
||||
def _local_now() -> datetime:
|
||||
"""The current time as an aware local datetime (a test seam)."""
|
||||
return datetime.now().astimezone()
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""Seconds on the monotonic clock (a test seam)."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _popen(cmd: list[str]) -> Popen[bytes]:
|
||||
"""Start ``cmd`` without waiting for it (a test seam)."""
|
||||
return subprocess.Popen(cmd)
|
||||
|
||||
|
||||
def _make_root() -> tk.Tk:
|
||||
"""A withdrawn Tk root; only the strip's Toplevel is ever visible."""
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
return root
|
||||
|
||||
|
||||
def read_entry() -> dict[str, object] | None:
|
||||
"""The file's parsed JSON object, or None. Verification is the caller's."""
|
||||
try:
|
||||
text = MORNING_SESSION_FILE.read_text()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
_once.warn(f"Cannot read {MORNING_SESSION_FILE}: {exc}")
|
||||
return None
|
||||
try:
|
||||
entry = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
_once.warn(f"Cannot parse {MORNING_SESSION_FILE}: {exc}")
|
||||
return None
|
||||
if not isinstance(entry, dict):
|
||||
_once.warn(f"{MORNING_SESSION_FILE} is not a JSON object")
|
||||
return None
|
||||
_once.clear()
|
||||
return entry
|
||||
|
||||
|
||||
def _why_not_today(entry: dict[str, object], now: datetime) -> str | None:
|
||||
"""Why ``entry`` is not a verified, live, in-progress morning; None if it is."""
|
||||
if not verify_entry_hmac(entry):
|
||||
return "morning session entry is unsigned or tampered"
|
||||
if entry.get("date") != now.date().isoformat():
|
||||
return f"morning session is dated {entry.get('date')}, not today"
|
||||
if entry.get("outcome") != "in_progress":
|
||||
return f"morning session outcome is {entry.get('outcome')}"
|
||||
return None
|
||||
|
||||
|
||||
def _exempt_until(entry: dict[str, object]) -> datetime | str:
|
||||
"""The entry's aware ``exempt_until``, or the sentence saying why not."""
|
||||
until = entry.get("exempt_until")
|
||||
if not isinstance(until, str):
|
||||
return "morning session has no exempt_until"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(until)
|
||||
except ValueError:
|
||||
return f"morning session exempt_until is unreadable: {until!r}"
|
||||
if parsed.tzinfo is None:
|
||||
# Guessing its zone would be inventing a carrot the signer never gave.
|
||||
return "morning session exempt_until has no timezone"
|
||||
return parsed
|
||||
|
||||
|
||||
def _verdict(entry: dict[str, object] | None, now: datetime) -> StripContent | str:
|
||||
"""The strip's content, or the sentence saying why there is none."""
|
||||
if entry is None:
|
||||
return "no morning session file"
|
||||
reason = _why_not_today(entry, now)
|
||||
if reason is not None:
|
||||
return reason
|
||||
exempt_until = _exempt_until(entry)
|
||||
if isinstance(exempt_until, str):
|
||||
return exempt_until
|
||||
if now >= exempt_until:
|
||||
return f"the exemption ended at {exempt_until.isoformat()}"
|
||||
lines: list[str] = []
|
||||
step = entry.get("next_step")
|
||||
if isinstance(step, str) and step:
|
||||
lines.append(f"Next: {step}")
|
||||
lines.append(f"Locks return at {exempt_until.astimezone(now.tzinfo):%H:%M}")
|
||||
return StripContent(TITLE, tuple(lines))
|
||||
|
||||
|
||||
def strip_content(
|
||||
entry: dict[str, object] | None, now: datetime
|
||||
) -> StripContent | None:
|
||||
"""What the strip says for ``entry`` at local ``now``; None shows nothing.
|
||||
|
||||
Something is shown only for a verified entry dated today, still
|
||||
``in_progress``, whose ``exempt_until`` has not passed.
|
||||
"""
|
||||
verdict = _verdict(entry, now)
|
||||
return verdict if isinstance(verdict, StripContent) else None
|
||||
|
||||
|
||||
class Refresher:
|
||||
"""Starts wake-alarm-session.service every 30 s, never two at once."""
|
||||
|
||||
def __init__(self, *, start: float) -> None:
|
||||
self._due = start + REFRESH_EVERY_SECONDS
|
||||
self._proc: Popen[bytes] | None = None
|
||||
|
||||
def tick(self, now: float) -> None:
|
||||
"""Reap a finished refresh, and start the next one when it is due."""
|
||||
if self._proc is not None:
|
||||
code = self._proc.poll()
|
||||
if code is None:
|
||||
return
|
||||
if code != 0:
|
||||
_logger.warning("the session refresh start exited %d", code)
|
||||
self._proc = None
|
||||
if now < self._due:
|
||||
return
|
||||
self._due = now + REFRESH_EVERY_SECONDS
|
||||
try:
|
||||
self._proc = _popen(REFRESH_COMMAND)
|
||||
except OSError as exc:
|
||||
_logger.warning("could not start the session refresh: %s", exc)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Show the strip while the morning is in progress; 0 when it is over."""
|
||||
configure_logging(component="session-strip")
|
||||
verdict = _verdict(read_entry(), _local_now())
|
||||
if not isinstance(verdict, StripContent):
|
||||
_logger.info("Nothing to show: %s", verdict)
|
||||
return 0
|
||||
try:
|
||||
root = _make_root()
|
||||
except tk.TclError as exc:
|
||||
_logger.warning("Cannot open the display for the strip: %s", exc)
|
||||
return 1
|
||||
rect = corner_rect(enumerate_outputs(root))
|
||||
if rect is None:
|
||||
_logger.warning("Cannot place the strip: no live output on this display")
|
||||
root.destroy()
|
||||
return 1
|
||||
strip = build_corner_strip(root, LockConfig(), verdict, rect=rect)
|
||||
refresher = Refresher(start=_monotonic())
|
||||
ending: list[str] = []
|
||||
|
||||
def supply() -> StripContent | None:
|
||||
current = _verdict(read_entry(), _local_now())
|
||||
if not isinstance(current, StripContent):
|
||||
ending.append(current)
|
||||
return None
|
||||
refresher.tick(_monotonic())
|
||||
return current
|
||||
|
||||
strip.schedule(supply, on_close=root.quit)
|
||||
root.mainloop()
|
||||
root.destroy()
|
||||
_logger.info("Strip closed: %s", ending[0] if ending else "window gone")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -38,6 +38,8 @@ import freedays
|
||||
import pytest
|
||||
import sickdays
|
||||
|
||||
from wake_alarm import _session_strip
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
@ -49,6 +51,33 @@ def _no_process(cmd: list[str]) -> object:
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""A monotonic clock that only moves when ``sleep`` is called.
|
||||
|
||||
Pass ``clock.sleep`` and ``clock.now`` to ``_session.refresh`` so a dead
|
||||
backend burns its 150 s retry budget instantly instead of for real.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.elapsed = 0.0
|
||||
self.sleeps: list[float] = []
|
||||
|
||||
def now(self) -> float:
|
||||
"""Seconds elapsed on the fake clock."""
|
||||
return self.elapsed
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
"""Advance the clock without waiting."""
|
||||
self.sleeps.append(seconds)
|
||||
self.elapsed += seconds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_clock() -> FakeClock:
|
||||
"""A fresh :class:`FakeClock`."""
|
||||
return FakeClock()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermetic(tmp_path: Path) -> Generator[None]:
|
||||
"""Keep signing keys, state writes and processes inside ``tmp_path``."""
|
||||
@ -67,6 +96,15 @@ def _hermetic(tmp_path: Path) -> Generator[None]:
|
||||
"wake_alarm._session.MORNING_SESSION_FILE",
|
||||
tmp_path / "morning_session.json",
|
||||
),
|
||||
# The strip imported the path by value too; its tick re-reads it every
|
||||
# second, its refresh spawns systemctl, and its warn-once memory must
|
||||
# not leak from one test into the next.
|
||||
patch(
|
||||
"wake_alarm._session_strip.MORNING_SESSION_FILE",
|
||||
tmp_path / "morning_session.json",
|
||||
),
|
||||
patch("wake_alarm._session_strip._popen", _no_process),
|
||||
patch("wake_alarm._session_strip._once", _session_strip._Once()),
|
||||
patch(
|
||||
"wake_alarm._workday_penalty.WORKDAY_PENALTY_FILE",
|
||||
tmp_path / "workday_penalty.json",
|
||||
|
||||
8
wake_alarm/tests/fixtures/session_legs.json
vendored
Normal file
8
wake_alarm/tests/fixtures/session_legs.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"legs": [
|
||||
{"name": "bathroomIn", "label": "Tap the bathroom tag"},
|
||||
{"name": "showerDone", "label": "Shower, then tap the bathroom tag again"},
|
||||
{"name": "dressed", "label": "Get dressed — tap the wardrobe tag"},
|
||||
{"name": "atDesk", "label": "Sit down — tap the desk tag"}
|
||||
]
|
||||
}
|
||||
@ -172,12 +172,6 @@ class TestRefresh:
|
||||
assert entry is not None
|
||||
assert entry["exempt_until"] == _local("2026-09-19T06:45:00").isoformat()
|
||||
|
||||
def test_unreadable_backend_leaves_the_file_alone(self) -> None:
|
||||
write_entry(to_entry(_record(), today="2026-09-19", refreshed_at=5.0))
|
||||
with patch("wake_alarm._session.fetch_firebase_logs", return_value=None):
|
||||
assert refresh(NOW) is None
|
||||
assert read_entry() is not None
|
||||
|
||||
def test_write_failure_is_none(self) -> None:
|
||||
with (
|
||||
patch("wake_alarm._session.fetch_firebase_logs", return_value=[]),
|
||||
|
||||
30
wake_alarm/tests/test_session_legs.py
Normal file
30
wake_alarm/tests/test_session_legs.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""The leg names and labels the PC reads must equal the phone's enum.
|
||||
|
||||
``fixtures/session_legs.json`` is one literal both sides assert: this test
|
||||
against ``_session_legs.LEGS`` and ``phone_app/test/services/
|
||||
session_legs_fixture_test.dart`` against ``SessionLeg``. Each side's own
|
||||
round-trip cannot catch a naming drift with its sibling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from wake_alarm._session_legs import LEGS, next_step
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "session_legs.json"
|
||||
|
||||
|
||||
def test_legs_equal_the_shared_fixture() -> None:
|
||||
legs = json.loads(FIXTURE.read_text(encoding="utf-8"))["legs"]
|
||||
assert [(leg["name"], leg["label"]) for leg in legs] == list(LEGS)
|
||||
|
||||
|
||||
def test_next_step_is_the_first_leg_not_passed() -> None:
|
||||
assert next_step(()) == "Tap the bathroom tag"
|
||||
assert next_step(("bathroomIn",)) == "Shower, then tap the bathroom tag again"
|
||||
# Order of the legs decides, not the order they were passed in.
|
||||
assert next_step(("showerDone",)) == "Tap the bathroom tag"
|
||||
assert next_step(("atDesk", "bathroomIn", "showerDone", "dressed")) is None
|
||||
assert next_step(("unknownLeg",)) == "Tap the bathroom tag"
|
||||
@ -64,6 +64,26 @@ class TestRecordFromJson:
|
||||
assert record.finished_at is None
|
||||
assert not record.finished
|
||||
|
||||
def test_passed_legs_are_the_keys_with_a_timestamp(self) -> None:
|
||||
passed = {
|
||||
"bathroomIn": {"at": "2026-09-19T08:05:00.000", "how": "tag"},
|
||||
"showerDone": "garbage",
|
||||
"dressed": {"how": "tag"},
|
||||
}
|
||||
record = record_from_json(
|
||||
_session("2026-09-19", "active", passed=passed),
|
||||
device_id="p",
|
||||
published_at=None,
|
||||
)
|
||||
assert record is not None
|
||||
assert record.passed_legs == ("bathroomIn",)
|
||||
for bad in (None, [], "x"):
|
||||
record = record_from_json(
|
||||
_session(passed=bad), device_id="p", published_at=None
|
||||
)
|
||||
assert record is not None
|
||||
assert record.passed_legs == ()
|
||||
|
||||
def test_rejects_every_malformed_shape(self) -> None:
|
||||
bad = [
|
||||
"text",
|
||||
|
||||
118
wake_alarm/tests/test_session_retry.py
Normal file
118
wake_alarm/tests/test_session_retry.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""``refresh`` retries an unreadable backend; ``next_step`` is signed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from wake_alarm import _session
|
||||
from wake_alarm._constants import REFRESH_POLL_SECONDS, REFRESH_RETRY_SECONDS
|
||||
from wake_alarm._session import read_entry, refresh, to_entry, write_entry
|
||||
from wake_alarm._session_payload import SessionRecord
|
||||
from wake_alarm._sync import Alarm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wake_alarm.tests.conftest import FakeClock
|
||||
|
||||
NOW = datetime(2026, 9, 19, 8, 30).astimezone()
|
||||
TODAY = "2026-09-19"
|
||||
|
||||
|
||||
def _live(passed: tuple[str, ...] = ("bathroomIn",)) -> SessionRecord:
|
||||
return SessionRecord(
|
||||
date=TODAY,
|
||||
outcome="in_progress",
|
||||
started_at="2026-09-19T08:01:00.000",
|
||||
finished_at=None,
|
||||
energy=3,
|
||||
weight_kg=73.8,
|
||||
re_rings=0,
|
||||
device_id="p",
|
||||
published_at=1.0,
|
||||
passed_legs=passed,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _alarm() -> object:
|
||||
with patch("wake_alarm._session.get_alarm", return_value=Alarm(8, 0, "cache")):
|
||||
yield
|
||||
|
||||
|
||||
class TestRefreshRetry:
|
||||
"""2026-11-07: booted without DNS; one try, then a 2-minute gap."""
|
||||
|
||||
def test_unreadable_backend_leaves_the_file_alone(
|
||||
self, fake_clock: FakeClock
|
||||
) -> None:
|
||||
write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0))
|
||||
with patch("wake_alarm._session.fetch_firebase_logs", return_value=None):
|
||||
assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None
|
||||
assert read_entry() is not None
|
||||
|
||||
def test_a_backend_that_recovers_is_retried_into_todays_entry(
|
||||
self, fake_clock: FakeClock
|
||||
) -> None:
|
||||
with patch(
|
||||
"wake_alarm._session.fetch_firebase_logs", side_effect=[None, None, []]
|
||||
) as fetch:
|
||||
entry = refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now)
|
||||
assert entry is not None
|
||||
assert (entry["date"], entry["outcome"]) == (TODAY, "none")
|
||||
assert read_entry() == entry
|
||||
assert fetch.call_count == 3
|
||||
assert fake_clock.sleeps == [REFRESH_POLL_SECONDS, REFRESH_POLL_SECONDS]
|
||||
|
||||
def test_a_dead_backend_gives_up_after_the_budget_and_says_so(
|
||||
self, caplog: pytest.LogCaptureFixture, fake_clock: FakeClock
|
||||
) -> None:
|
||||
with (
|
||||
patch("wake_alarm._session.fetch_firebase_logs", return_value=None),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None
|
||||
assert fake_clock.sleeps == [REFRESH_POLL_SECONDS] * (
|
||||
REFRESH_RETRY_SECONDS // REFRESH_POLL_SECONDS
|
||||
)
|
||||
warnings = [r.getMessage() for r in caplog.records if r.levelno >= 30]
|
||||
assert any(f"{REFRESH_RETRY_SECONDS} s" in w for w in warnings)
|
||||
|
||||
def test_sick_day_cancel_runs_once_before_the_loop(
|
||||
self, fake_clock: FakeClock
|
||||
) -> None:
|
||||
with (
|
||||
patch("wake_alarm._session.fetch_firebase_logs", return_value=None),
|
||||
patch("wake_alarm._session._cancel_for_sick_day") as cancel,
|
||||
):
|
||||
refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now)
|
||||
cancel.assert_called_once()
|
||||
|
||||
|
||||
class TestNextStep:
|
||||
"""The entry names the next leg and signs it."""
|
||||
|
||||
def test_in_progress_entry_names_the_next_leg(self) -> None:
|
||||
entry = to_entry(_live(), today=TODAY, refreshed_at=5.0)
|
||||
assert entry["next_step"] == "Shower, then tap the bathroom tag again"
|
||||
|
||||
def test_next_step_is_null_unless_the_morning_is_live(self) -> None:
|
||||
done = replace(_live(), outcome="completed", finished_at="2026-09-19T08:40")
|
||||
assert to_entry(done, today=TODAY, refreshed_at=5.0)["next_step"] is None
|
||||
failed = replace(_live(), outcome="failed")
|
||||
assert to_entry(failed, today=TODAY, refreshed_at=5.0)["next_step"] is None
|
||||
assert to_entry(None, today=TODAY, refreshed_at=5.0)["next_step"] is None
|
||||
|
||||
def test_tampering_next_step_breaks_verification(self) -> None:
|
||||
write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0))
|
||||
assert read_entry() is not None
|
||||
path = _session.MORNING_SESSION_FILE
|
||||
tampered = json.loads(path.read_text())
|
||||
tampered["next_step"] = None
|
||||
path.write_text(json.dumps(tampered))
|
||||
assert read_entry() is None
|
||||
128
wake_alarm/tests/test_session_strip.py
Normal file
128
wake_alarm/tests/test_session_strip.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""The corner strip's content rules, file reader and thin seams."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, timedelta
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from gatelock.corner_strip import StripContent
|
||||
import pytest
|
||||
|
||||
from wake_alarm import _session_strip
|
||||
from wake_alarm.tests.test_strip_support import NOW, STEP, signed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# conftest replaces _popen for every test; the real one is captured at import.
|
||||
REAL_POPEN = _session_strip._popen
|
||||
|
||||
|
||||
class TestStripContent:
|
||||
def test_in_progress_says_next_step_and_when_locks_return(self) -> None:
|
||||
assert _session_strip.strip_content(signed(), NOW) == StripContent(
|
||||
"Morning session not finished",
|
||||
(f"Next: {STEP}", "Locks return at 08:45"),
|
||||
)
|
||||
|
||||
def test_next_step_null_drops_the_next_line(self) -> None:
|
||||
content = _session_strip.strip_content(signed(next_step=None), NOW)
|
||||
assert content == StripContent(
|
||||
"Morning session not finished", ("Locks return at 08:45",)
|
||||
)
|
||||
|
||||
def test_exempt_until_is_shown_in_local_time(self) -> None:
|
||||
utc = (NOW + timedelta(minutes=45)).astimezone(UTC).isoformat()
|
||||
content = _session_strip.strip_content(signed(exempt_until=utc), NOW)
|
||||
assert content is not None
|
||||
assert content.lines[-1] == "Locks return at 08:45"
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["completed", "failed", "none", "weird"])
|
||||
def test_other_outcomes_show_nothing(self, outcome: str) -> None:
|
||||
assert _session_strip.strip_content(signed(outcome=outcome), NOW) is None
|
||||
|
||||
def test_yesterday_shows_nothing(self) -> None:
|
||||
assert _session_strip.strip_content(signed(date="2026-11-07"), NOW) is None
|
||||
|
||||
def test_expired_shows_nothing(self) -> None:
|
||||
later = NOW + timedelta(minutes=45)
|
||||
assert _session_strip.strip_content(signed(), later) is None
|
||||
|
||||
def test_tampered_shows_nothing(self) -> None:
|
||||
entry = signed()
|
||||
entry["next_step"] = "Something else"
|
||||
assert _session_strip.strip_content(entry, NOW) is None
|
||||
|
||||
def test_unsigned_shows_nothing(self) -> None:
|
||||
entry = signed()
|
||||
del entry["hmac"]
|
||||
assert _session_strip.strip_content(entry, NOW) is None
|
||||
|
||||
def test_missing_entry_shows_nothing(self) -> None:
|
||||
assert _session_strip.strip_content(None, NOW) is None
|
||||
|
||||
@pytest.mark.parametrize("until", [None, 5, "garbage", "2026-11-08T09:00:00"])
|
||||
def test_unusable_exempt_until_shows_nothing(self, until: object) -> None:
|
||||
assert _session_strip.strip_content(signed(exempt_until=until), NOW) is None
|
||||
|
||||
@pytest.mark.parametrize("step", [5, ""])
|
||||
def test_unusable_next_step_is_left_out(self, step: object) -> None:
|
||||
content = _session_strip.strip_content(signed(next_step=step), NOW)
|
||||
assert content is not None
|
||||
assert content.lines == ("Locks return at 08:45",)
|
||||
|
||||
|
||||
class TestReadEntry:
|
||||
def test_missing_file_is_none_and_quiet(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
assert _session_strip.read_entry() is None
|
||||
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||
|
||||
def test_reads_the_file(self) -> None:
|
||||
entry = signed()
|
||||
_session_strip.MORNING_SESSION_FILE.write_text(json.dumps(entry))
|
||||
assert _session_strip.read_entry() == entry
|
||||
|
||||
@pytest.mark.parametrize("text", ["{nope", "[1, 2]"])
|
||||
def test_unreadable_file_warns_once_per_state(
|
||||
self, text: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_session_strip.MORNING_SESSION_FILE.write_text(text)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _session_strip.read_entry() is None
|
||||
assert _session_strip.read_entry() is None
|
||||
assert len([r for r in caplog.records if r.levelno >= logging.WARNING]) == 1
|
||||
|
||||
def test_directory_in_the_way_warns(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
_session_strip.MORNING_SESSION_FILE.mkdir()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _session_strip.read_entry() is None
|
||||
assert "Cannot read" in caplog.text
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_local_now_is_aware(self) -> None:
|
||||
assert _session_strip._local_now().tzinfo is not None
|
||||
|
||||
def test_monotonic_moves_forward(self) -> None:
|
||||
first = _session_strip._monotonic()
|
||||
assert _session_strip._monotonic() >= first
|
||||
|
||||
def test_make_root_builds_a_withdrawn_tk_root(self) -> None:
|
||||
with patch("wake_alarm._session_strip.tk.Tk") as tk_root:
|
||||
root = _session_strip._make_root()
|
||||
root.withdraw.assert_called_once()
|
||||
assert root is tk_root.return_value
|
||||
|
||||
def test_popen_runs_the_command(self) -> None:
|
||||
with patch("wake_alarm._session_strip.subprocess.Popen") as popen:
|
||||
REAL_POPEN(["true"])
|
||||
popen.assert_called_once_with(["true"])
|
||||
|
||||
def test_log_state_path_is_the_conftest_scratch(self, tmp_path: Path) -> None:
|
||||
assert _session_strip.MORNING_SESSION_FILE.parent == tmp_path
|
||||
196
wake_alarm/tests/test_session_strip_run.py
Normal file
196
wake_alarm/tests/test_session_strip_run.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""The corner strip's refresher and its main loop, on a fake Tk."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from gatelock.corner_strip import StripContent
|
||||
import pytest
|
||||
|
||||
from wake_alarm import _session_strip
|
||||
from wake_alarm.tests.test_strip_support import (
|
||||
NOW,
|
||||
STEP,
|
||||
Display,
|
||||
Procs,
|
||||
signed,
|
||||
write,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
|
||||
class TestRefresher:
|
||||
@pytest.fixture
|
||||
def procs(self) -> Procs:
|
||||
fake = Procs()
|
||||
with patch("wake_alarm._session_strip._popen", fake):
|
||||
yield fake
|
||||
|
||||
def test_first_refresh_is_thirty_seconds_in(self, procs: Procs) -> None:
|
||||
refresher = _session_strip.Refresher(start=100.0)
|
||||
refresher.tick(129.0)
|
||||
assert procs.started == []
|
||||
refresher.tick(130.0)
|
||||
assert procs.started == [
|
||||
["systemctl", "--user", "start", "--no-block", "wake-alarm-session.service"]
|
||||
]
|
||||
|
||||
def test_one_start_per_interval(self, procs: Procs) -> None:
|
||||
refresher = _session_strip.Refresher(start=0.0)
|
||||
for second in range(30, 60):
|
||||
refresher.tick(float(second))
|
||||
procs.procs[-1].poll.return_value = 0
|
||||
assert len(procs.started) == 1
|
||||
refresher.tick(60.0)
|
||||
assert len(procs.started) == 2
|
||||
|
||||
def test_no_second_spawn_while_the_first_runs(self, procs: Procs) -> None:
|
||||
refresher = _session_strip.Refresher(start=0.0)
|
||||
refresher.tick(30.0)
|
||||
refresher.tick(60.0)
|
||||
refresher.tick(95.0)
|
||||
assert len(procs.started) == 1
|
||||
procs.procs[0].poll.return_value = 0
|
||||
refresher.tick(96.0)
|
||||
assert len(procs.started) == 2
|
||||
|
||||
def test_failed_exit_warns(
|
||||
self, procs: Procs, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
refresher = _session_strip.Refresher(start=0.0)
|
||||
refresher.tick(30.0)
|
||||
procs.procs[0].poll.return_value = 5
|
||||
with caplog.at_level(logging.WARNING):
|
||||
refresher.tick(31.0)
|
||||
assert "exited 5" in caplog.text
|
||||
|
||||
def test_spawn_failure_warns_and_waits_for_the_next_interval(
|
||||
self, procs: Procs, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
procs.error = FileNotFoundError("systemctl")
|
||||
refresher = _session_strip.Refresher(start=0.0)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
refresher.tick(30.0)
|
||||
refresher.tick(31.0)
|
||||
assert caplog.text.count("could not start") == 1
|
||||
procs.error = None
|
||||
refresher.tick(60.0)
|
||||
assert len(procs.started) == 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def display(monkeypatch: pytest.MonkeyPatch) -> Generator[Display]:
|
||||
"""A fake Tk world with a pinned clock and a recording ``_popen``."""
|
||||
fake = Display()
|
||||
monkeypatch.setattr("wake_alarm._session_strip._local_now", lambda: NOW)
|
||||
monkeypatch.setattr("wake_alarm._session_strip._monotonic", lambda: 0.0)
|
||||
monkeypatch.setattr("wake_alarm._session_strip._popen", Procs())
|
||||
with ExitStack() as stack:
|
||||
for item in fake.patches():
|
||||
stack.enter_context(item)
|
||||
yield fake
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_nothing_to_show_exits_zero_without_a_window(
|
||||
self, display: Display, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
write(signed(outcome="completed"))
|
||||
with caplog.at_level(logging.INFO):
|
||||
assert _session_strip.main() == 0
|
||||
display.root.mainloop.assert_not_called()
|
||||
assert display.built == []
|
||||
assert "completed" in caplog.text
|
||||
|
||||
def test_missing_file_exits_zero(self, display: Display) -> None:
|
||||
assert _session_strip.main() == 0
|
||||
assert display.built == []
|
||||
|
||||
def test_no_output_is_a_warning_and_a_failure(
|
||||
self, display: Display, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
write(signed())
|
||||
display.rect = None
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _session_strip.main() == 1
|
||||
assert "no live output" in caplog.text
|
||||
display.root.destroy.assert_called_once()
|
||||
|
||||
def test_shows_then_closes_when_the_session_completes(
|
||||
self, display: Display
|
||||
) -> None:
|
||||
write(signed())
|
||||
|
||||
def flip(tick: int) -> None:
|
||||
if tick == 3:
|
||||
write(signed(outcome="completed", next_step=None))
|
||||
|
||||
display.on_tick = flip
|
||||
assert _session_strip.main() == 0
|
||||
assert display.built == [
|
||||
StripContent(
|
||||
"Morning session not finished",
|
||||
(f"Next: {STEP}", "Locks return at 08:45"),
|
||||
)
|
||||
]
|
||||
assert display.ticks == 3
|
||||
display.window.destroy.assert_called_once()
|
||||
display.root.quit.assert_called_once()
|
||||
display.root.destroy.assert_called_once()
|
||||
|
||||
def test_closes_when_the_exemption_ends(
|
||||
self, display: Display, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
write(signed())
|
||||
|
||||
def expire(tick: int) -> None:
|
||||
if tick == 2:
|
||||
monkeypatch.setattr(
|
||||
"wake_alarm._session_strip._local_now",
|
||||
lambda: NOW + timedelta(minutes=45),
|
||||
)
|
||||
|
||||
display.on_tick = expire
|
||||
assert _session_strip.main() == 0
|
||||
assert display.ticks == 2
|
||||
|
||||
def test_refresh_starts_while_visible_and_not_after_close(
|
||||
self, display: Display, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
procs = Procs()
|
||||
clock = {"t": 0.0}
|
||||
monkeypatch.setattr("wake_alarm._session_strip._popen", procs)
|
||||
monkeypatch.setattr("wake_alarm._session_strip._monotonic", lambda: clock["t"])
|
||||
write(signed())
|
||||
|
||||
def advance(tick: int) -> None:
|
||||
clock["t"] = tick * 10.0
|
||||
if tick == 4:
|
||||
write(signed(outcome="completed"))
|
||||
|
||||
display.on_tick = advance
|
||||
assert _session_strip.main() == 0
|
||||
# Ticks run at t=10, 20, 30 (refresh due), then 40 closes the strip.
|
||||
assert len(procs.started) == 1
|
||||
|
||||
def test_unopenable_display_is_logged_and_fails(
|
||||
self, display: Display, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
import tkinter as tk
|
||||
|
||||
write(signed())
|
||||
with (
|
||||
patch(
|
||||
"wake_alarm._session_strip._make_root",
|
||||
side_effect=tk.TclError("no display"),
|
||||
),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
assert _session_strip.main() == 1
|
||||
assert "no display" in caplog.text
|
||||
@ -7,6 +7,7 @@ from datetime import date, datetime
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@ -32,6 +33,9 @@ from wake_alarm._workday_penalty import (
|
||||
write_penalty,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wake_alarm.tests.conftest import FakeClock
|
||||
|
||||
TUESDAY = date(2026, 9, 22)
|
||||
AFTER_GRACE = datetime(2026, 9, 22, 9, 0).astimezone()
|
||||
MISS: dict[str, object] = {
|
||||
@ -131,10 +135,15 @@ class TestRetraction:
|
||||
)
|
||||
assert not cancel_for_sick_day()
|
||||
|
||||
def test_refresh_retracts_even_offline(self) -> None:
|
||||
def test_refresh_retracts_even_offline(self, fake_clock: FakeClock) -> None:
|
||||
mark_sick(TUESDAY)
|
||||
with patch.object(_session, "fetch_firebase_logs", return_value=None):
|
||||
assert _session.refresh(AFTER_GRACE) is None
|
||||
assert (
|
||||
_session.refresh(
|
||||
AFTER_GRACE, sleep=fake_clock.sleep, clock=fake_clock.now
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert issued_date() is None
|
||||
|
||||
|
||||
|
||||
103
wake_alarm/tests/test_strip_support.py
Normal file
103
wake_alarm/tests/test_strip_support.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Shared fakes for the corner-strip tests: signed entries, Tk and Popen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from gatelock.corner_strip import CornerStrip, StripContent
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
|
||||
from wake_alarm import _session_strip
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
TZ = timezone(timedelta(hours=1))
|
||||
NOW = datetime(2026, 11, 8, 8, 0, tzinfo=TZ)
|
||||
STEP = "Shower, then tap the bathroom tag again"
|
||||
|
||||
|
||||
def signed(**overrides: object) -> dict[str, object]:
|
||||
"""A verified in-progress entry for NOW's day, with fields overridden."""
|
||||
entry: dict[str, object] = {
|
||||
"date": "2026-11-08",
|
||||
"outcome": "in_progress",
|
||||
"exempt_until": (NOW + timedelta(minutes=45)).isoformat(),
|
||||
"next_step": STEP,
|
||||
}
|
||||
entry.update(overrides)
|
||||
entry["hmac"] = compute_entry_hmac(entry)
|
||||
return entry
|
||||
|
||||
|
||||
class Procs:
|
||||
"""A fake ``_popen`` recording starts; each proc finishes when told."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.started: list[list[str]] = []
|
||||
self.procs: list[MagicMock] = []
|
||||
self.error: OSError | None = None
|
||||
|
||||
def __call__(self, cmd: list[str]) -> MagicMock:
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
self.started.append(cmd)
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
self.procs.append(proc)
|
||||
return proc
|
||||
|
||||
|
||||
class Display:
|
||||
"""A fake Tk world: records the strip's tick callbacks and drives them."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.root = MagicMock(name="root")
|
||||
self.window = MagicMock(name="window")
|
||||
self.callbacks: list[Callable[[], None]] = []
|
||||
self.window.after.side_effect = self._after
|
||||
self.root.quit.side_effect = self.callbacks.clear
|
||||
self.root.mainloop.side_effect = self._mainloop
|
||||
self.ticks = 0
|
||||
self.on_tick: Callable[[int], None] = lambda _n: None
|
||||
self.rect: object = MagicMock(name="rect")
|
||||
self.built: list[StripContent] = []
|
||||
|
||||
def _after(self, _ms: int, callback: Callable[[], None]) -> str:
|
||||
self.callbacks.append(callback)
|
||||
return "job"
|
||||
|
||||
def _mainloop(self) -> None:
|
||||
while self.callbacks:
|
||||
self.ticks += 1
|
||||
self.on_tick(self.ticks)
|
||||
self.callbacks.pop(0)()
|
||||
if self.ticks > 50: # runaway-loop guard
|
||||
msg = "strip never closed"
|
||||
raise AssertionError(msg)
|
||||
|
||||
def build(
|
||||
self, _root: object, _config: object, content: StripContent, **kwargs: object
|
||||
) -> object:
|
||||
|
||||
assert kwargs["rect"] is self.rect
|
||||
self.built.append(content)
|
||||
return CornerStrip(self.window, MagicMock(), [MagicMock(), MagicMock()])
|
||||
|
||||
def patches(self) -> list[object]:
|
||||
return [
|
||||
patch("wake_alarm._session_strip._make_root", return_value=self.root),
|
||||
patch("wake_alarm._session_strip.enumerate_outputs"),
|
||||
patch(
|
||||
"wake_alarm._session_strip.corner_rect",
|
||||
side_effect=lambda _scan: self.rect,
|
||||
),
|
||||
patch("wake_alarm._session_strip.build_corner_strip", self.build),
|
||||
]
|
||||
|
||||
|
||||
def write(entry: dict[str, object]) -> None:
|
||||
_session_strip.MORNING_SESSION_FILE.write_text(json.dumps(entry))
|
||||
Loading…
Reference in New Issue
Block a user