mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
On 2026-09-19 the phone was powered off after the bathroom tag and nothing on the PC noticed: every finished session was published and nothing read it. Now wake-alarm distils the phone's morning into an HMAC-signed ~/.local/state/wake_alarm/morning_session.json (wake-alarm-session.timer, 07:00-11:00 every 2 min) that screen-locker and leetcode-guard read to defer their locks -- provisionally until min(start + 90 min, 09:30) while the session is live, until 11:00 once it completed inside that cutoff, and not at all for a session finished later (the phone switched on at noon completes the legs then; that must un-lock nothing). The phone publishes the session at start as well as at finish (current_session, cleared with the finish stamp), so the PC can tell "started, not finished" from "never started". A required 1-5 energy tap joins the weigh-in and ships as latest_energy with a PC reader mirroring _weight (the shared half is _field_reader). Also fixes the vmbox harness, red since the 2026-09-11 home-tidy: the guest tree lives at ~/wake-alarm, and the INSTALL-OK grep matched vm's echo of the command line. Takes Flutter 3.47.5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tp54ewdXRLaimUuT8LgYyn
240 lines
8.2 KiB
Dart
240 lines
8.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:wake_alarm_sync/screens/session_screen.dart';
|
|
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
|
import 'package:wake_alarm_sync/services/morning_session.dart';
|
|
import 'package:wake_alarm_sync/services/session_rules.dart';
|
|
import 'package:wake_alarm_sync/services/session_store.dart';
|
|
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late List<MethodCall> calls;
|
|
late DateTime now;
|
|
var ringing = false;
|
|
var published = 0;
|
|
|
|
void mockChannel() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(
|
|
const MethodChannel(AlarmScheduler.channelName),
|
|
(call) async {
|
|
calls.add(call);
|
|
if (call.method == 'hasNfc') return true;
|
|
if (call.method == 'isRinging') return ringing;
|
|
return null;
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Delivers a tag from the platform side, as `NfcReader` does.
|
|
Future<void> tap(String tagId) async {
|
|
const codec = StandardMethodCodec();
|
|
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.handlePlatformMessage(
|
|
AlarmScheduler.channelName,
|
|
codec.encodeMethodCall(MethodCall('onNfcTag', tagId)),
|
|
(_) {},
|
|
);
|
|
}
|
|
|
|
List<String> methods() => calls.map((c) => c.method).toList();
|
|
Future<void> tick(WidgetTester tester, [int seconds = 1]) async {
|
|
for (var i = 0; i < seconds; i++) {
|
|
await tester.pump(kSessionTick);
|
|
}
|
|
await tester.pump();
|
|
}
|
|
|
|
setUp(() async {
|
|
calls = <MethodCall>[];
|
|
now = DateTime(2026, 9, 17, 8, 4);
|
|
ringing = false;
|
|
published = 0;
|
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
|
mockChannel();
|
|
await SessionTagSettings.replaceAll(<String>['bath', 'ward', 'desk'], '');
|
|
});
|
|
|
|
Future<void> pumpSession(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
routes: <String, WidgetBuilder>{
|
|
'/': (_) => SessionScreen(
|
|
clock: () => now,
|
|
publish: () async {
|
|
published++;
|
|
return 'pushed';
|
|
},
|
|
),
|
|
'/ring': (_) => const Scaffold(body: Text('RING')),
|
|
},
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
}
|
|
|
|
/// Taps energy 3 (required since the check-in) and submits [kg].
|
|
Future<void> logWeight(WidgetTester tester, String kg) async {
|
|
await tester.tap(find.widgetWithText(ChoiceChip, '3'));
|
|
await tester.pump();
|
|
await tester.enterText(find.byType(TextField), kg);
|
|
await tester.tap(find.text('Log weight'));
|
|
await tester.pump();
|
|
}
|
|
|
|
testWidgets('starts a session cold and arms the first deadline', (
|
|
tester,
|
|
) async {
|
|
await pumpSession(tester);
|
|
expect(find.text('Stay up'), findsOneWidget);
|
|
expect(find.text('Weigh in'), findsOneWidget);
|
|
expect(find.text('Alarm returns in 10:00'), findsOneWidget);
|
|
final deadline = now.add(MorningSession.legWindow).millisecondsSinceEpoch;
|
|
final arm = calls.firstWhere((c) => c.method == 'armReRing');
|
|
expect(arm.arguments, <String, int?>{'atMillis': deadline});
|
|
// The launcher-redirect record follows the deadline in the same breath.
|
|
final notice = calls.firstWhere((c) => c.method == 'updateSessionNotice');
|
|
expect(notice.arguments, <String, Object?>{
|
|
'date': '2026-09-17',
|
|
'label': 'Tap the bathroom tag',
|
|
'deadlineMillis': deadline,
|
|
});
|
|
expect((await SessionStore().loadCurrent())?.date, '2026-09-17');
|
|
});
|
|
|
|
testWidgets('every leg change re-points the native notice', (tester) async {
|
|
await pumpSession(tester);
|
|
await logWeight(tester, '72');
|
|
await tap('bath');
|
|
await tester.pump();
|
|
final labels = calls
|
|
.where((c) => c.method == 'updateSessionNotice')
|
|
.map((c) => (c.arguments as Map<Object?, Object?>)['label'])
|
|
.toList();
|
|
expect(labels.last, 'Shower, then tap the bathroom tag again');
|
|
expect(methods(), isNot(contains('beginMotionWatch')));
|
|
});
|
|
|
|
testWidgets('walks every leg, then finishes, archives and closes', (
|
|
tester,
|
|
) async {
|
|
await pumpSession(tester);
|
|
await logWeight(tester, '72,4');
|
|
expect(find.textContaining('now tap the bathroom tag'), findsOneWidget);
|
|
expect(find.byType(TextField), findsNothing);
|
|
await tap('desk');
|
|
await tester.pump();
|
|
expect(find.text('Not the bathroom tag'), findsOneWidget);
|
|
await tap('bath');
|
|
await tester.pump();
|
|
expect(find.text('Weigh in — 72.4 kg · energy 3/5'), findsOneWidget);
|
|
expect(find.textContaining('tag counts in 08:00'), findsOneWidget);
|
|
await tap('bath');
|
|
await tester.pump();
|
|
await tester.pump();
|
|
expect(find.textContaining('Too early'), findsOneWidget);
|
|
now = now.add(const Duration(minutes: 9));
|
|
await tick(tester);
|
|
expect(find.textContaining('Alarm returns in'), findsOneWidget);
|
|
for (final id in <String>['bath', 'ward', 'desk']) {
|
|
await tap(id);
|
|
}
|
|
await tester.pump();
|
|
await tester.pump();
|
|
expect(methods(), contains('finishSession'));
|
|
expect(methods().last, 'closeRingScreen');
|
|
expect(published, 2); // once at start, once at finish
|
|
final store = SessionStore();
|
|
expect(await store.loadCurrent(), isNull);
|
|
final done = (await store.history()).single;
|
|
expect(done.outcome, SessionOutcome.completed);
|
|
expect(done.weightKg, 72.4);
|
|
expect(done.reRings, 0);
|
|
});
|
|
|
|
testWidgets('a native ring already sounding routes to the ring screen', (
|
|
tester,
|
|
) async {
|
|
await pumpSession(tester);
|
|
await logWeight(tester, '72');
|
|
await tap('bath');
|
|
now = now.add(const Duration(minutes: 9));
|
|
await tap('bath');
|
|
await tester.pump();
|
|
ringing = true;
|
|
await tick(tester);
|
|
await tester.pumpAndSettle();
|
|
expect(methods(), contains('ringNow'));
|
|
expect(find.text('RING'), findsOneWidget);
|
|
final saved = (await SessionStore().loadCurrent())!;
|
|
expect(saved.pendingReRingReason, 'deadline:dressed');
|
|
expect(saved.currentLeg, SessionLeg.dressed);
|
|
});
|
|
|
|
testWidgets('a silent phone keeps ticking without ringing', (tester) async {
|
|
await pumpSession(tester);
|
|
await logWeight(tester, '72');
|
|
await tap('bath');
|
|
await tick(tester, 5);
|
|
expect(methods(), contains('isRinging'));
|
|
expect(methods(), isNot(contains('ringNow')));
|
|
});
|
|
|
|
testWidgets('a missed deadline the alarm did not fire rings from Dart', (
|
|
tester,
|
|
) async {
|
|
await pumpSession(tester);
|
|
now = now.add(MorningSession.legWindow).add(const Duration(seconds: 30));
|
|
await tick(tester);
|
|
await tester.pumpAndSettle();
|
|
expect(methods(), contains('ringNow'));
|
|
expect(
|
|
(await SessionStore().loadCurrent())!.pendingReRingReason,
|
|
'deadline:bathroomIn',
|
|
);
|
|
});
|
|
|
|
testWidgets('resumes a stored session and bypasses a spent leg', (
|
|
tester,
|
|
) async {
|
|
final started = now.subtract(const Duration(minutes: 16));
|
|
final session = SessionRules.start(
|
|
now: started,
|
|
tags: await SessionTagSettings.load(),
|
|
nfcAvailable: true,
|
|
)..recordReRing(now.subtract(const Duration(minutes: 1)));
|
|
await SessionStore().save(session);
|
|
await pumpSession(tester);
|
|
expect(find.text('Re-rings: 1/3'), findsOneWidget);
|
|
expect(
|
|
calls.firstWhere((c) => c.method == 'armReRing').arguments,
|
|
<String, int?>{'atMillis': null},
|
|
);
|
|
await tick(tester);
|
|
expect(find.textContaining('Skipped'), findsOneWidget);
|
|
expect(find.textContaining('(skipped by the clock)'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a big jump must be typed twice', (tester) async {
|
|
final store = SessionStore();
|
|
await store.archive(
|
|
SessionRules.start(
|
|
now: now.subtract(const Duration(days: 1)),
|
|
tags: const SessionTagSettings(),
|
|
nfcAvailable: false,
|
|
)..weightKg = 70,
|
|
);
|
|
await pumpSession(tester);
|
|
await logWeight(tester, '75');
|
|
expect(find.textContaining('type it again'), findsOneWidget);
|
|
await logWeight(tester, '75');
|
|
expect(find.textContaining('now tap the bathroom tag'), findsOneWidget);
|
|
expect(find.byType(TextField), findsNothing);
|
|
});
|
|
}
|