diff --git a/lib/ui/insights/coach_cards.dart b/lib/ui/insights/coach_cards.dart index 3053fd8..dab806a 100644 --- a/lib/ui/insights/coach_cards.dart +++ b/lib/ui/insights/coach_cards.dart @@ -35,6 +35,27 @@ Map? _val(Object? metric) { return v is Map ? v.cast() : null; } +/// The alarm-status caption for the Sleep page, from the SESSION-scoped +/// confirmation machine only. Pure + public so the "only speak after an +/// in-session SET" rule is unit-tested. +/// +/// The confirmation state machine ([AppState.alarm…]) is reset on every launch +/// and only advances once the user actually writes a SET this session, so all +/// three inputs are false on a fresh open. A persisted `alarmEpoch` (an alarm +/// set in a PREVIOUS session) is deliberately NOT an input: keying the caption +/// off it made the page read "Alarm sent, waiting for the strap to confirm." on +/// a fresh open when the user never tapped the button. All-false → null (idle). +String? alarmStatusCaption({ + required bool confirmed, + required bool pending, + required bool unconfirmed, +}) { + if (confirmed) return 'Alarm set ✓'; + if (pending) return 'Setting alarm…'; + if (unconfirmed) return 'Alarm sent, waiting for the strap to confirm.'; + return null; +} + // ── SLEEP COACH ────────────────────────────────────────────────────────────── /// Tonight's sleep need + recommended bedtime / wake, last night's sleep @@ -95,21 +116,22 @@ class _SleepCoachCardState extends State { } /// Live alarm-status caption driven by the strap's own confirmation events. - /// Null when no alarm has been set yet — nothing to say. - String? _alarmCaption(AppState app) { - if (app.alarmEpoch == null) return null; - if (app.alarmConfirmed) return 'Alarm set ✓'; - if (app.alarmPending) return 'Setting alarm…'; - return 'Alarm sent, waiting for the strap to confirm.'; - } + /// Null when no alarm is being tracked THIS session — nothing to say. + String? _alarmCaption(AppState app) => alarmStatusCaption( + confirmed: app.alarmConfirmed, + pending: app.alarmPending, + unconfirmed: app.alarmUnconfirmed, + ); @override Widget build(BuildContext context) { // Rebuild only on the 3 alarm fields _alarmCaption reads — was two // separate context.watch() calls below (each one subscribing - // to ALL 67 notifyListeners() sources, not just alarm state). - context.select( - (a) => (a.alarmEpoch, a.alarmConfirmed, a.alarmPending), + // to ALL 67 notifyListeners() sources, not just alarm state). The grace + // timer's notifyListeners() flips alarmPending true→false, which changes + // this tuple, so the unconfirmed transition still triggers a rebuild. + context.select( + (a) => (a.alarmConfirmed, a.alarmPending, a.alarmUnconfirmed), ); if (_loading) return const SizedBox.shrink(); final need = _val(_coach?['need']); diff --git a/test/sleep_alarm_caption_test.dart b/test/sleep_alarm_caption_test.dart new file mode 100644 index 0000000..5ef942d --- /dev/null +++ b/test/sleep_alarm_caption_test.dart @@ -0,0 +1,82 @@ +// Regression tests for the Sleep page showing "Alarm sent, waiting for the +// strap to confirm." when the user never set an alarm. +// +// Root cause: the caption used to fall through to the "awaiting confirmation" +// text whenever a PERSISTED alarm epoch existed (an alarm set in a previous +// session), even though the strap-confirmation state machine is session-scoped +// and had recorded no SET this session. On a fresh open the epoch was restored +// from SharedPreferences but confirmed/pending/unconfirmed were all false, so +// the fallthrough fired the pending caption with no user tap. +// +// The fix routes the caption purely through the session-scoped machine's +// confirmed/pending/unconfirmed flags — never the persisted epoch — so an +// all-false state (fresh launch, no tap) yields no caption. These tests pin the +// mapping, driven by the real [AlarmConfirmation] transitions. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_state.dart'; +import 'package:openstrap_edge/ui/insights/coach_cards.dart'; + +// Caption as the widget builds it, from a confirmation machine at [nowMs]. +String? captionFor(AlarmConfirmation a, int nowMs) => alarmStatusCaption( + confirmed: a.confirmed, + pending: a.isPending(nowMs), + unconfirmed: a.isUnconfirmed(nowMs), + ); + +void main() { + group('alarmStatusCaption', () { + test('fresh session (no in-session SET) shows NO caption', () { + // This is the bug: a persisted epoch alone must not surface a caption. + final a = AlarmConfirmation(); + expect(captionFor(a, 0), isNull); + }); + + test('after a SET this session → "Setting alarm…" inside the grace window', () { + final a = AlarmConfirmation(graceMs: 6000); + a.set(1750000000, 0); + expect(captionFor(a, 0), 'Setting alarm…'); + expect(captionFor(a, 5999), 'Setting alarm…'); + }); + + test('SET with no confirm past the grace window → awaiting-confirm caption', () { + final a = AlarmConfirmation(graceMs: 6000); + a.set(1750000000, 0); + expect(captionFor(a, 6000), 'Alarm sent, waiting for the strap to confirm.'); + }); + + test('strap confirms (event 56) → "Alarm set ✓"', () { + final a = AlarmConfirmation(graceMs: 6000); + a.set(1750000000, 0); + a.onEvent(AlarmConfirmation.kEvtSet, 100); + expect(captionFor(a, 10000), 'Alarm set ✓'); + }); + + test('disable clears the caption back to idle', () { + final a = AlarmConfirmation(graceMs: 6000); + a.set(1750000000, 0); + a.onEvent(AlarmConfirmation.kEvtSet, 100); + a.onEvent(AlarmConfirmation.kEvtDisabled, 200); + expect(captionFor(a, 10000), isNull); + }); + + test('pure mapping: precedence confirmed > pending > unconfirmed > null', () { + expect( + alarmStatusCaption(confirmed: true, pending: true, unconfirmed: true), + 'Alarm set ✓', + ); + expect( + alarmStatusCaption(confirmed: false, pending: true, unconfirmed: true), + 'Setting alarm…', + ); + expect( + alarmStatusCaption(confirmed: false, pending: false, unconfirmed: true), + 'Alarm sent, waiting for the strap to confirm.', + ); + expect( + alarmStatusCaption(confirmed: false, pending: false, unconfirmed: false), + isNull, + ); + }); + }); +}