updated the sheet

This commit is contained in:
2026-08-28 18:16:28 +05:30
parent 69f4f3e909
commit 074cc0eccf
43 changed files with 2617 additions and 1201 deletions

View 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);
});
});
}