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,353 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/device_telemetry.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/providers/Riderlog/riderlog_provider.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE TELEMETRY NEVER LEFT THE PHONE
///
/// ── What the dispatcher saw ──
///
/// Battery, Charging, Connection, GPS and Location Service, all blank, on
/// riders whose handsets were measuring every one of them correctly.
///
/// ── Where it actually stopped ──
///
/// Not in [DeviceTelemetry] — `device_telemetry_test.dart` pins that the
/// reading and the payload are right. Not in the callers, which all spread
/// `toPayload()` into the heartbeat. It stopped one layer further down, at a
/// gate in `riderlog_provider.dart`:
///
/// ```
/// if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data);
/// return _heartbeat(data);
/// ```
///
/// `_heartbeat` is the **only** caller of `POST /miler/logs`, and also the only
/// caller of `PUT /miler/location`. Both were unreachable whenever `dutylogid`
/// was zero — and it stayed zero in two ordinary cases, permanently, with
/// nothing anywhere reporting an error:
///
/// • `duty/start` returning a body that does not name the id under that exact
/// key. The zero was then *stored*, so every later tick re-entered
/// `_startDuty`.
/// • `duty/start` answering 400 because the server already had the rider on
/// duty. The reconciliation through `duty/current` recovered the duty state
/// and wrote the id to `logid`/`logId` — never to `dutylogid`.
///
/// These tests drive the provider against a recording client, so what they
/// assert is what the phone would actually put on the wire.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
late List<http.Request> sent;
/// The heartbeat payload a caller builds — the real shape, from
/// `RiderLogController.createLoginNowV2` and the foreground service, both of
/// which spread `DeviceTelemetry.toPayload()` into it.
Map<String, dynamic> payload({int onduty = 1}) => {
'userid': 90001,
'latitude': '12.9716',
'longitude': '77.5946',
'speed': '0',
'heading': '0',
'accuracy': '8.5',
'status': 'active',
'onduty': onduty,
...const DeviceTelemetry(
battery: 72,
isCharging: false,
connection: 'wifi',
locationService: 'enabled',
).toPayload(),
'is_background': false,
};
/// Answers every route with a success envelope. [dutyStartBody] is what
/// `POST /miler/duty/start` returns, which is the variable the bug turned on.
void stub({
Map<String, dynamic> dutyStartBody = const {},
int dutyStartStatus = 200,
Map<String, dynamic> dutyCurrentBody = const {'onduty': true},
}) {
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
final path = req.url.path;
Map<String, dynamic> body = const {};
int status = 200;
if (path.endsWith('/duty/start')) {
body = dutyStartBody;
status = dutyStartStatus;
} else if (path.endsWith('/duty/current')) {
body = dutyCurrentBody;
}
return http.Response(
jsonEncode({'success': status < 400, 'data': body}),
status,
headers: const {'content-type': 'application/json'},
);
});
}
List<http.Request> to(String suffix) =>
sent.where((r) => r.url.path.endsWith(suffix)).toList();
Map<String, dynamic> bodyOf(http.Request r) =>
jsonDecode(r.body) as Map<String, dynamic>;
/// `POST /miler/logs` is deliberately fire-and-forget inside `_heartbeat` — a
/// telemetry failure must never read as a location failure, because the
/// caller treats the latter as duty state going wrong. So the request is in
/// flight, not finished, when the provider returns.
Future<void> settle() => pumpEventQueue();
setUp(() {
SharedPreferences.setMockInitialValues({});
stub();
});
tearDown(() => MilerApi.client = http.Client());
group('the telemetry reaches POST /miler/logs', () {
test(
'every field the console draws is on the wire, under its own name',
() async {
// Duty already established, so this is an ordinary heartbeat tick.
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
final logs = to('/miler/logs');
expect(logs, hasLength(1), reason: 'the heartbeat must post one log');
final body = bodyOf(logs.single);
// The names are the wire's. A rename is a console column that goes blank
// with nothing else failing.
expect(body['battery'], '72');
expect(body['is_charging'], false);
expect(body['connection'], 'wifi');
expect(body['location_service'], 'enabled');
expect(body['accuracy'], '8.5');
expect(body['is_background'], false);
expect(body['latitude'], '12.9716');
expect(body['longitude'], '77.5946');
},
);
test('and the location write still happens on the same tick', () async {
// `PUT /miler/location` is the Redis geo-index dispatch searches. It
// shares `_heartbeat` with the telemetry, so it was silenced by the same
// gate — and it must not be lost while fixing the other half.
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/location'), hasLength(1));
expect(to('/miler/location').single.method, 'PUT');
});
test(
'REGRESSION · the log post completes before the heartbeat returns',
() async {
// ── Why this matters more than it looks ──
//
// `POST /miler/logs` was `unawaited(...)`, to keep a telemetry failure
// from reading as a location failure. On Android the heartbeat runs
// inside `flutter_foreground_task`'s own Flutter engine, spun up per
// tick — and when the callback returns, that engine can be suspended
// before an in-flight future finishes. So the awaited
// `PUT /miler/location` landed every tick and this post was killed
// mid-flight.
//
// The backend saw it before we did: a rider with a live position in
// Redis and no telemetry row behind it, while the app logged a
// successful heartbeat.
//
// Asserting *without* pumping the event queue is the whole point: if
// the post ever goes back to being fire-and-forget, the request has not
// been made yet at this line and this fails.
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
await CreateRiderLogProvider().createRiderLog(payload());
expect(
to('/miler/logs'),
hasLength(1),
reason: 'the telemetry post must finish before the tick returns',
);
},
);
test('no fix means no post, which is the contract, not a bug', () async {
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
await CreateRiderLogProvider().createRiderLog({
...payload(),
'latitude': '',
'longitude': '',
});
await settle();
expect(to('/miler/logs'), isEmpty);
expect(to('/miler/location'), isEmpty);
});
});
group('the gate that swallowed it', () {
test(
'REGRESSION · duty/start that does not name the id still heartbeats',
() async {
// The exact defect: an empty `data` body. The old code parsed 0, STORED
// 0, and every later tick re-entered `_startDuty` — so `/miler/logs`
// was never posted, on the rider's first shift, forever.
stub(dutyStartBody: const {});
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), hasLength(1));
expect(
to('/miler/logs'),
hasLength(1),
reason: 'starting duty must not cost the rider the tick that did it',
);
// And the next tick is a plain heartbeat, not another start attempt.
final before = to('/miler/duty/start').length;
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), hasLength(before));
expect(to('/miler/logs'), hasLength(2));
},
);
test(
'REGRESSION · "already on duty" reconciles AND opens the gate',
() async {
// `duty/start` 400s because the server already has him on duty — after
// a reinstall, cleared storage, or an `endDuty` that never landed. The
// reconciliation through `duty/current` used to write the id to
// `logid`/`logId` and not to `dutylogid`, so the gate stayed shut and
// the next tick came straight back here. Permanently.
stub(
dutyStartStatus: 400,
dutyCurrentBody: const {'onduty': true, 'dutylogid': 77},
);
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/current'), hasLength(1));
expect(
to('/miler/logs'),
hasLength(1),
reason: 'a reconciled rider must report, not loop on duty/start',
);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getInt('dutylogid'), 77);
// The loop is broken: no second start attempt.
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), hasLength(1));
expect(to('/miler/logs'), hasLength(2));
},
);
test('the id is found however the response spells it', () async {
// One key on one level was the whole lookup. These are shapes this
// contract has worn; a miss is no longer fatal, but the legacy `logid`
// readers still want the number.
for (final key in ['dutylogid', 'dutyLogId', 'logid', 'id']) {
SharedPreferences.setMockInitialValues({});
stub(dutyStartBody: {key: 501});
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getInt('dutylogid'),
501,
reason: 'the id was sent as `$key`',
);
}
});
test('an install from before the flag keeps reporting', () async {
// A rider mid-shift when he takes the update. He must not have to go off
// duty and on again to start reporting, and he must not re-start duty.
SharedPreferences.setMockInitialValues({'dutylogid': 909});
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), isEmpty);
expect(to('/miler/logs'), hasLength(1));
});
});
group('the update path is gated the same way', () {
test(
'an on-duty update establishes duty and beats on the same tick',
() async {
stub(dutyStartBody: const {});
await UpdateRiderLogProvider().updateRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), hasLength(1));
expect(to('/miler/logs'), hasLength(1));
},
);
test('going off duty ends it and stops the heartbeat', () async {
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
await UpdateRiderLogProvider().updateRiderLog({
...payload(onduty: 0),
'onduty': 0,
});
await settle();
expect(to('/miler/duty/end'), hasLength(1));
expect(
to('/miler/logs'),
isEmpty,
reason: 'an off-duty payload is not a heartbeat',
);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getInt('dutylogid'), 0);
// And the next tick starts duty again rather than beating into a shift
// that has ended.
await CreateRiderLogProvider().createRiderLog(payload());
await settle();
expect(to('/miler/duty/start'), hasLength(1));
});
test('duty/current reporting off duty closes the gate', () async {
// The server is the authority. If it says the rider is off, the app must
// not keep beating on a stale local flag.
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
stub(dutyCurrentBody: const {'onduty': false});
await GetRiderLogProvider().getRiderLog();
await settle();
final prefs = await SharedPreferences.getInstance();
expect(prefs.getInt('onduty'), 0);
expect(prefs.getInt('dutylogid'), 0);
});
});
}

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

View File

@@ -0,0 +1,251 @@
import 'dart:convert';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/device_telemetry.dart';
import 'package:miler/data/heartbeat.dart';
import 'package:miler/data/miler_api.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE `POST /miler/logs` CONTRACT, PINNED FIELD BY FIELD
///
/// The gate that stopped the heartbeat reaching this endpoint is fixed
/// elsewhere (`duty_heartbeat_gate_test.dart`). This is the layer under it: of
/// the requests that *do* go out, are they in the shape the handler parses?
///
/// Four of these were wrong, and every one of them fails silently. Nothing 400s
/// in a way the app surfaces, the row is written or dropped server-side, and
/// the only symptom is a console panel that is blank or stale:
///
/// 1. **`logdate` was IST wall-clock.** The handler parses it as UTC and uses
/// it as a sort score, so every point from an Indian handset was stamped
/// +05:30 into the future — outside any recent-window the console asks for,
/// and filed under tomorrow after 18:30 local.
/// 2. **`status` was `active` / `idle`.** Neither word appears in the API. The
/// field takes an availability value, and this backend drops unrecognised
/// enums silently.
/// 3. **`connection` was the plugin's enum constant** — `wifi`, `mobile`,
/// `ethernet` — not the contract's `WiFi` / `4G` / `none`.
/// 4. **`location_service` could be `denied` or `denied_forever`**, which the
/// field does not define.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('logdate is the phone\'s local wall clock', () {
// ── Reverted from UTC, on the backend team's correction ──
//
// UTC was the intuitive answer for a field with no zone suffix, and it is
// the wrong one for this backend: `DBNow` / `DBToday` and the database
// itself run on IST wall-clock. A UTC stamp from an Indian handset lands
// 5h30m behind the console's window, and after 18:30 local it files the
// evening under the previous day.
//
// Written against local fields rather than a fixed string, so the suite
// passes wherever it runs — including CI on a UTC host, which is precisely
// the machine that would not have caught the original mistake.
String two(int n) => n.toString().padLeft(2, '0');
test('a local DateTime is rendered exactly as its own wall clock', () {
final at = DateTime(2026, 8, 27, 21, 30, 0);
expect(MilerApi.logStamp(at), '2026-08-27 21:30:00');
});
test('a UTC instant is converted to local, not printed as UTC', () {
final utc = DateTime.utc(2026, 8, 27, 16, 0, 0);
final local = utc.toLocal();
expect(
MilerApi.logStamp(utc),
'${local.year}-${two(local.month)}-${two(local.day)} '
'${two(local.hour)}:${two(local.minute)}:${two(local.second)}',
);
});
test('the evening does not roll into the previous day', () {
// The case UTC actually broke: 21:30 IST is 16:00 UTC the same day, but
// 00:30 IST is 19:00 UTC the day *before*. The date must follow the
// rider's clock, because that is the day the console groups by.
final at = DateTime(2026, 8, 27, 23, 45, 0);
expect(MilerApi.logStamp(at), '2026-08-27 23:45:00');
});
test('the format is what the handler parses, with no zone suffix', () {
final stamp = MilerApi.logStamp(DateTime(2026, 1, 2, 3, 4, 5));
expect(stamp, '2026-01-02 03:04:05');
expect(stamp, matches(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'));
});
});
group('status is an availability value', () {
test('both values the heartbeat can send are in the enum', () {
for (final hasWork in [true, false]) {
final status = heartbeatStatus(hasActiveWork: hasWork);
expect(
MilerApi.availabilityStatuses,
contains(status),
reason: '`$status` is not a value the backend accepts',
);
}
});
test('the words that were there before are not in the enum', () {
// Pinned so the old strings cannot come back through a merge.
expect(MilerApi.availabilityStatuses, isNot(contains('active')));
expect(MilerApi.availabilityStatuses, isNot(contains('idle')));
});
test('a rider at a counter and a rider between stops differ', () {
expect(heartbeatStatus(hasActiveWork: true), 'On_Pickup');
expect(heartbeatStatus(hasActiveWork: false), 'Available');
});
});
group('the telemetry vocabulary is the contract, not the plugin', () {
test('connection is WiFi / 4G / none', () {
const t = DeviceTelemetry(connection: 'WiFi');
expect(t.toPayload()['connection'], 'WiFi');
});
test('location_service is only ever enabled or disabled', () async {
// `read` collapses the richer answers the fix lookup produces. The
// distinction between off and denied still rides on the MQTT alert.
for (final raw in ['denied', 'denied_forever', 'unknown', 'disabled']) {
final t = await DeviceTelemetry.read(locationService: raw);
final v = t.toPayload()['location_service'];
expect(
v,
anyOf(isNull, 'enabled', 'disabled'),
reason: '`$raw` must not reach the wire as itself',
);
expect(v, isNot('denied'));
}
});
test('enabled survives, because it is the one that means yes', () async {
final t = await DeviceTelemetry.read(locationService: 'enabled');
expect(t.toPayload()['location_service'], 'enabled');
});
});
group('the request on the wire', () {
late List<http.Request> sent;
setUp(() {
SharedPreferences.setMockInitialValues({});
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
return http.Response(
jsonEncode({'success': true, 'data': {}}),
200,
headers: const {'content-type': 'application/json'},
);
});
});
tearDown(() => MilerApi.client = http.Client());
Future<Map<String, dynamic>> post() async {
await MilerApi.postLog(
latitude: 12.9716,
longitude: 77.5946,
speed: 4.2,
heading: 91.0,
accuracy: 8.5,
status: heartbeatStatus(hasActiveWork: true),
orderId: 25,
battery: 72,
isCharging: true,
connection: 'WiFi',
locationService: 'enabled',
isBackground: true,
);
return jsonDecode(sent.single.body) as Map<String, dynamic>;
}
test('every numeric field is a string', () async {
final body = await post();
for (final key in [
'latitude',
'longitude',
'speed',
'heading',
'accuracy',
'battery',
]) {
expect(
body[key],
isA<String>(),
reason: '`$key` must be a string — real numbers fail to parse',
);
}
});
test('is_charging and is_background stay real booleans', () async {
final body = await post();
expect(body['is_charging'], isA<bool>());
expect(body['is_background'], isA<bool>());
expect(body['is_charging'], isTrue);
expect(body['is_background'], isTrue);
});
test('userid is never sent — the token owns it', () async {
final body = await post();
expect(body.containsKey('userid'), isFalse);
expect(body.containsKey('userId'), isFalse);
});
test(
'one point per call, because the handler parses one MilerLog',
() async {
await post();
expect(sent, hasLength(1));
expect(jsonDecode(sent.single.body), isA<Map<String, dynamic>>());
expect(
jsonDecode(sent.single.body),
isNot(isA<List>()),
reason: 'this endpoint is not the batched one',
);
},
);
test('the whole documented field set is present', () async {
final body = await post();
expect(
body.keys,
containsAll(<String>[
'logdate',
'latitude',
'longitude',
'speed',
'heading',
'accuracy',
'status',
'orderid',
'battery',
'is_charging',
'connection',
'location_service',
'is_background',
]),
);
});
});
group('the connectivity mapping', () {
// Driven through `read` because the mapper is private; on the test host
// the plugin is absent, so this pins the shape rather than the wiring.
test('the enum this plugin version returns is a list, not a scalar', () {
// connectivity_plus 6+ reports every active transport. Reading `.first`
// made a phone on Wi-Fi with mobile data up report either one, depending
// on the order the platform happened to return them.
expect(ConnectivityResult.values, contains(ConnectivityResult.wifi));
expect(ConnectivityResult.values, contains(ConnectivityResult.mobile));
expect(ConnectivityResult.values, contains(ConnectivityResult.none));
});
});
}