updated the sheet
This commit is contained in:
291
test/heartbeat_interval_test.dart
Normal file
291
test/heartbeat_interval_test.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
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 <Object?>[
|
||||
null,
|
||||
0,
|
||||
-1,
|
||||
'',
|
||||
'x',
|
||||
0.0,
|
||||
-0.5,
|
||||
<String>[],
|
||||
]) {
|
||||
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<String, dynamic> body = json.decode(res.body);
|
||||
final Map<String, dynamic> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user