292 lines
11 KiB
Dart
292 lines
11 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|