import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/controllers/riderlog.dart'; import 'package:miler/data/heartbeat.dart'; import 'package:miler/data/mock_backend.dart'; import 'package:miler/providers/auth/auth_provider.dart'; import 'package:miler/xpress/controllers/riderlog.dart' as xpress; /// ───────────────────────────────────────────────────────────────────────── /// THE HEARTBEAT THAT NEVER STARTED /// /// ── What the dispatcher saw ── /// /// A rider on duty, and a console panel with an em dash against Battery, /// Charging, Connection, GPS Accuracy and Location Service. The obvious /// suspicion — that the phone was not measuring any of it — was wrong; /// `device_telemetry_test.dart` already pins that the reader and the payload /// are correct. /// /// ── What was actually happening ── /// /// One missing field, three call sites, and a zero that was read as an /// instruction instead of as an absence: /// /// 1. `POST /miler/verify-pin` on the v1 contract answers with /// `{success, token, user:{…, profile:{…}}}`. There is no `logseconds` in /// it — that field belonged to the legacy login. /// 2. `AuthProvider.loginParsed` mapped the response into the legacy `details` /// shape and persisted `details['logseconds'] ?? 0`, so every rider's /// stored cadence was **zero**. /// 3. `setOnDuty` started the loop only `if (interval > 0)`, and /// `startAutoCreateLoginLoop` returned at `if (interval <= 0)`. Both gates /// were shut, on every rider, on every shift. /// /// The loop that did not start is the only thing that calls `_heartbeat()`, /// and `_heartbeat()` is the only thing that writes **both** `POST /miler/logs` /// (the trail the console's device columns are drawn from) and /// `PUT /miler/location` (the geo-index dispatch searches to find a rider at /// all). So the silence was not only cosmetic. /// /// Nothing threw and nothing logged an error, which is why it survived so long: /// the app reported duty on, posted exactly one log, and then went quiet for /// the rest of the shift. /// ───────────────────────────────────────────────────────────────────────── void main() { group('resolveLogSeconds — a cadence has a floor, not a zero', () { test('a real cadence from the hub is honoured exactly', () { // The legacy backend does send this, and a hub that tunes it must win. expect(resolveLogSeconds(15), 15); expect(resolveLogSeconds(120), 120); }); test('missing, zero and negative all mean "the backend did not say"', () { // The three shapes the bug actually arrived in. Zero is the one that // mattered: it is what `?? 0` wrote and what both gates read. expect(resolveLogSeconds(null), kDefaultLogSeconds); expect(resolveLogSeconds(0), kDefaultLogSeconds); expect(resolveLogSeconds(-30), kDefaultLogSeconds); }); test('a numeric string is a cadence too', () { // `details['logseconds']` is untyped JSON and has been seen as both. expect(resolveLogSeconds('45'), 45); expect(resolveLogSeconds(' 45 '), 45); expect(resolveLogSeconds('0'), kDefaultLogSeconds); expect(resolveLogSeconds('later'), kDefaultLogSeconds); expect(resolveLogSeconds(''), kDefaultLogSeconds); }); test('the floor is never zero, whatever it is handed', () { // The single property the whole fix rests on. Any return of 0 here puts // the old `interval <= 0` behaviour back, whatever the call sites say. for (final input in [ null, 0, -1, '', 'x', 0.0, -0.5, [], ]) { expect( resolveLogSeconds(input), greaterThan(0), reason: 'resolveLogSeconds($input) must never disable the heartbeat', ); } }); test('the default matches the cadence a live pickup forces', () { // Both controllers hard-code 30 for the live-pickup case, and the pickup // log's own interval falls back to 30. A different default here would // give one rider's trail two shapes depending on what he was doing. expect(kDefaultLogSeconds, 30); }); }); group('the v1 login stores a cadence the loop can start on', () { setUp(() async { SharedPreferences.setMockInitialValues({}); MockBackend.enabled = true; }); tearDown(() { MockBackend.enabled = false; }); test('verify-pin has no logseconds, so the adapter supplies the floor', () { // The mock's verify-pin body is the real v1 shape, verified against a // live 200 — `{success, token, user:{…, profile:{…}}}` and no cadence // anywhere in it. That absence is the whole bug. final canned = MockBackend.respond( 'POST', '/miler/verify-pin', body: const {'phone': '9000000001', 'pin': '1234'}, ); expect(canned, isNotNull); expect(canned!.containsKey('logseconds'), isFalse); expect((canned['user'] as Map).containsKey('logseconds'), isFalse); }); test('the legacy details map carries 30, not 0', () async { final res = await AuthProvider().login( contactNo: '9000000001', deviceType: 'android', configId: 1, deviceId: 'test-device', fcmToken: '', pinRaw: '1234', ); final Map body = json.decode(res.body); final Map details = body['details']; // Read back by `loginParsed` and written to prefs. A zero here is the // stored zero that shut both gates. expect(details['logseconds'], kDefaultLogSeconds); expect(details['logseconds'], greaterThan(0)); }); test( 'the stored cadence would pass even the gate that was removed', () async { // `setOnDuty` used to read this pref and compare it against zero. The // assertion is deliberately written as the *old* condition: it is the // one that decided whether a rider reported for his whole shift. final prefs = await SharedPreferences.getInstance(); await prefs.setInt( 'logseconds', resolveLogSeconds(null), // what the v1 adapter now supplies ); final int interval = resolveLogSeconds(prefs.getInt('logseconds')); expect(interval > 0, isTrue); expect(interval, 30); }, ); }); group('the loop starts when the backend never sent a cadence', () { setUp(() { MockBackend.enabled = true; }); tearDown(() { MockBackend.enabled = false; }); test('startAutoCreateLoginLoop runs with logseconds absent', () async { // Exactly the state a v1 rider is in after signing in and going on duty: // on duty, and no cadence stored at all. SharedPreferences.setMockInitialValues({'onduty': 1}); final ctl = RiderLogController(); addTearDown(ctl.stopAutoCreateLoginLoop); await ctl.startAutoCreateLoginLoop(); await pumpEventQueue(); // Before the fix this returned at `if (interval <= 0)` and left the // subscription null — duty on, nothing reporting. expect( ctl.autoLoopRunning, isTrue, reason: 'a rider on duty must beat even when the hub set no cadence', ); }); test( 'and with logseconds explicitly zero, which is what was stored', () async { SharedPreferences.setMockInitialValues({'onduty': 1, 'logseconds': 0}); final ctl = RiderLogController(); addTearDown(ctl.stopAutoCreateLoginLoop); await ctl.startAutoCreateLoginLoop(); await pumpEventQueue(); expect(ctl.autoLoopRunning, isTrue); }, ); test('off duty still does not beat', () async { // The fix removes the cadence gate, not the duty gate. A rider who is // off duty is not tracked, and that must stay true. SharedPreferences.setMockInitialValues({'onduty': 0}); final ctl = RiderLogController(); addTearDown(ctl.stopAutoCreateLoginLoop); await ctl.startAutoCreateLoginLoop(); await pumpEventQueue(); expect(ctl.autoLoopRunning, isFalse); }); test('calling it again does not leave two loops running', () async { // Three places call this — going on duty, resuming the app, and a pickup // going live — and they overlap in practice. `stopAutoCreateLoginLoop` // runs first, so the last caller wins and there is never a second // subscription posting a duplicate trail. SharedPreferences.setMockInitialValues({'onduty': 1}); final ctl = RiderLogController(); addTearDown(ctl.stopAutoCreateLoginLoop); await ctl.startAutoCreateLoginLoop(); await pumpEventQueue(); expect(ctl.autoLoopRunning, isTrue); // The live-pickup path, which passes its own cadence. await ctl.startAutoCreateLoginLoop(seconds: 30); await pumpEventQueue(); expect(ctl.autoLoopRunning, isTrue); await ctl.stopAutoCreateLoginLoop(); expect( ctl.autoLoopRunning, isFalse, reason: 'one stop must be enough to silence the heartbeat', ); }); test('an explicit cadence from the caller is still honoured', () async { SharedPreferences.setMockInitialValues({'onduty': 1}); final ctl = RiderLogController(); addTearDown(ctl.stopAutoCreateLoginLoop); await ctl.startAutoCreateLoginLoop(seconds: 45); await pumpEventQueue(); expect(ctl.autoLoopRunning, isTrue); }); }); group('the meal line has the same fix', () { setUp(() { MockBackend.enabled = true; }); tearDown(() { MockBackend.enabled = false; }); // `lib/xpress` is a parallel copy of the rider controller for the // cloud-kitchen line, switched by tenant. It carried the identical two // gates, so a meal-run rider was as silent as a logistics one. test('xpress starts its loop with no cadence stored', () async { SharedPreferences.setMockInitialValues({'onduty': 1}); final ctl = xpress.RiderLogController(); addTearDown(ctl.stopAutoCreateLoginLoop); await ctl.startAutoCreateLoginLoop(); await pumpEventQueue(); expect(ctl.autoLoopRunning, isTrue); }); test('xpress still respects duty', () async { SharedPreferences.setMockInitialValues({'onduty': 0}); final ctl = xpress.RiderLogController(); addTearDown(ctl.stopAutoCreateLoginLoop); await ctl.startAutoCreateLoginLoop(); await pumpEventQueue(); expect(ctl.autoLoopRunning, isFalse); }); }); }