866 lines
34 KiB
Dart
866 lines
34 KiB
Dart
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/Models/stop_status.dart';
|
|
import 'package:miler/controllers/riderkm.dart';
|
|
import 'package:miler/data/api_config.dart';
|
|
import 'package:miler/data/miler_api.dart';
|
|
import 'package:miler/data/milk_run.dart';
|
|
import 'package:miler/data/route_order.dart';
|
|
import 'package:miler/data/service_profile.dart';
|
|
import 'package:miler/views/Dashboard/home/homepage.dart';
|
|
import 'package:miler/data/stop_compliance.dart';
|
|
import 'package:miler/controllers/pickups_controller.dart';
|
|
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// FOUR CONTRACTS THE APP MEETS THE BACKEND ON
|
|
///
|
|
/// Each of these has already been wrong once, and each was wrong in the same
|
|
/// way: the app read a field the server does not send, or ignored one it does.
|
|
/// None of them raised anything — an absent key and an empty answer are the
|
|
/// same value, so every failure was silent and every symptom was cosmetic
|
|
/// ("the chart is empty", "the route is in the wrong order"). That is what
|
|
/// makes them worth pinning rather than trusting.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 1 · ROUTE SEQUENCING
|
|
//
|
|
// The backend's rule, given 25 Aug: `sequencedat` is the authority, not
|
|
// `step`. Non-null → a route was assigned, follow `step` exactly. Null → no
|
|
// route, and nearest-first is correct rather than a fallback for a bug.
|
|
//
|
|
// `RouteOrder` implemented that correctly and it never ran, because
|
|
// `pickupFromBooking` was dropping the field. Every adapted row looked
|
|
// unsequenced and the app re-sorted routes the hub had solved.
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('1 · sequencedat survives the adapter and decides the order', () {
|
|
Map<String, dynamic> booking(int id, Map<String, dynamic> extra) => {
|
|
'bookingid': id,
|
|
'bookingno': 'B$id',
|
|
'pickupaddress': '$id Thadagam Road, Coimbatore',
|
|
'pickupcustomer': 'Sri Balaji Stores',
|
|
...extra,
|
|
};
|
|
|
|
/// The rider is beside stop 3, so nearest-first would put it first. Any
|
|
/// test that expects the hub's order has to be able to lose to proximity
|
|
/// if the gate fails — otherwise it passes for the wrong reason.
|
|
double distanceTo(Map<String, dynamic> s) => switch ('${s['orderid']}') {
|
|
'3' => 10,
|
|
'1' => 4000,
|
|
'2' => 9000,
|
|
_ => 99999,
|
|
};
|
|
|
|
test('a sequenced booking keeps its stamp and its step', () {
|
|
final row = ApiConfig.pickupFromBooking(
|
|
booking(1, {'step': 2, 'sequencedat': '2026-08-25T09:00:00+05:30'}),
|
|
);
|
|
|
|
expect(
|
|
RouteOrder.isSequenced(row),
|
|
isTrue,
|
|
reason: 'the stamp did not survive pickupFromBooking',
|
|
);
|
|
expect(RouteOrder.sequenceOf(row), 2);
|
|
});
|
|
|
|
test('the hub order is followed, and proximity does not override it', () {
|
|
final stops = [
|
|
for (final (id, step) in const [(3, 3), (1, 1), (2, 2)])
|
|
ApiConfig.pickupFromBooking(
|
|
booking(id, {
|
|
'step': step,
|
|
'sequencedat': '2026-08-25T09:00:00+05:30',
|
|
}),
|
|
),
|
|
];
|
|
|
|
final (sorted, source) = RouteOrder.sort(stops, distanceTo: distanceTo);
|
|
|
|
expect(source, RouteOrderSource.adminSequence);
|
|
expect(
|
|
sorted.map((s) => '${s['orderid']}').toList(),
|
|
['1', '2', '3'],
|
|
reason:
|
|
'the rider is standing on B3 — if this comes back B3-first the '
|
|
'stamp was ignored and nearest-first won',
|
|
);
|
|
});
|
|
|
|
test('no stamp means no route, and the fallback is correct', () {
|
|
// The backend's own words: `step: 0` with a null stamp is a rider holding
|
|
// fewer than two active stops, or a stop with no coordinates. Neither is
|
|
// a route, and re-sorting is the right answer rather than a workaround.
|
|
final stops = [
|
|
for (final id in const [1, 2, 3])
|
|
ApiConfig.pickupFromBooking(
|
|
booking(id, {'step': 0, 'sequencedat': null}),
|
|
),
|
|
];
|
|
|
|
expect(RouteOrder.hasAdminSequence(stops), isFalse);
|
|
|
|
final (sorted, source) = RouteOrder.sort(stops, distanceTo: distanceTo);
|
|
expect(source, isNot(RouteOrderSource.adminSequence));
|
|
expect(
|
|
'${sorted.first['orderid']}',
|
|
'3',
|
|
reason: 'with no route assigned, the nearest stop leads',
|
|
);
|
|
});
|
|
|
|
test('a blank stamp is no stamp', () {
|
|
final row = ApiConfig.pickupFromBooking(
|
|
booking(1, {'step': 0, 'sequencedat': ' '}),
|
|
);
|
|
expect(RouteOrder.isSequenced(row), isFalse);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 2 · PER-STOP DISTANCE
|
|
//
|
|
// Three spellings, because the app writes two of them itself: the contract's
|
|
// `compliance.actualkm`, the backend's `riderkms` on a raw booking row, and
|
|
// `actualkms` — what `PickupsController` posts on the arrival and pickup
|
|
// writes, so it is the key on any record stamped locally before the queue
|
|
// caught up. Reading only the first two made a stop the rider had just
|
|
// finished report no distance at all.
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('2 · every distance spelling resolves', () {
|
|
test('compliance.actualkm — the contract\'s own', () {
|
|
final c = StopCompliance.of({
|
|
'compliance': {'actualkm': 4.2},
|
|
});
|
|
expect(c.actualKm, 4.2);
|
|
});
|
|
|
|
test('riderkms — a row straight off the API', () {
|
|
expect(StopCompliance.of({'riderkms': 3.1}).actualKm, 3.1);
|
|
});
|
|
|
|
test('actualkms — a row this app stamped itself', () {
|
|
expect(StopCompliance.of({'actualkms': '2.75'}).actualKm, 2.75);
|
|
});
|
|
|
|
test('the contract wins when more than one is present', () {
|
|
final c = StopCompliance.of({
|
|
'compliance': {'actualkm': 9.9},
|
|
'riderkms': 3.1,
|
|
'actualkms': '2.75',
|
|
});
|
|
expect(
|
|
c.actualKm,
|
|
9.9,
|
|
reason: 'the authoritative figure must outrank the locally stamped one',
|
|
);
|
|
});
|
|
|
|
test('riderkms outranks the local stamp', () {
|
|
final c = StopCompliance.of({'riderkms': 3.1, 'actualkms': '2.75'});
|
|
expect(c.actualKm, 3.1);
|
|
});
|
|
|
|
test('no distance at all is null, not zero', () {
|
|
// Null is what the UI turns into the em dash. A zero would render as a
|
|
// real measurement of nothing, which is a different claim.
|
|
expect(StopCompliance.of(const {}).actualKm, isNull);
|
|
expect(StopCompliance.of({'riderkms': 0}).actualKm, isNull);
|
|
expect(StopCompliance.of({'actualkms': ''}).actualKm, isNull);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 3 · THE WEEK'S DISTANCE
|
|
//
|
|
// `GET /miler/earnings` answers with six totals for the period asked for and
|
|
// no series. The app read a `breakdown` array that is not on the contract, so
|
|
// it was always null and the chart drew an empty week while the rider had
|
|
// ridden all of it.
|
|
//
|
|
// Seven daily calls build the week now. The fast path stays, so the day the
|
|
// field ships the seven calls stop — and that switch has to be automatic and
|
|
// has to refuse anything it cannot draw.
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('3 · weekly distance: fast path and fallback', () {
|
|
late List<Uri> asked;
|
|
|
|
/// Answers `weekly` with [weekly] and every `daily` with its own total.
|
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
|
|
|
void stub({
|
|
Map<String, dynamic> weekly = const {'total_kms': 40},
|
|
double dailyKm = 5,
|
|
}) {
|
|
asked = <Uri>[];
|
|
MilerApi.client = MockClient((req) async {
|
|
asked.add(req.url);
|
|
final period = req.url.queryParameters['period'];
|
|
final body = period == 'weekly' ? weekly : {'total_kms': dailyKm};
|
|
return http.Response(
|
|
jsonEncode({'success': true, 'data': body}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
}
|
|
|
|
int dailyCalls() =>
|
|
asked.where((u) => u.queryParameters['period'] == 'daily').length;
|
|
|
|
test(
|
|
'FAST PATH · a valid breakdown is used, and no daily call is made',
|
|
() async {
|
|
stub(
|
|
weekly: {
|
|
'total_kms': 40.5,
|
|
// The backend's own example shape: ISO dates, not weekday names.
|
|
'breakdown': [
|
|
{'day': '2026-08-19', 'kms': 18.4},
|
|
{'day': '2026-08-20', 'kms': 22.1},
|
|
],
|
|
},
|
|
);
|
|
|
|
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
|
final details = res['details'] as List;
|
|
|
|
expect(details, hasLength(2));
|
|
expect(res['total_kms'], 40.5);
|
|
expect(
|
|
dailyCalls(),
|
|
0,
|
|
reason: 'the seven daily calls must stop the day breakdown ships',
|
|
);
|
|
},
|
|
);
|
|
|
|
test('FAST PATH · an ISO date is labelled with the day it names', () async {
|
|
stub(
|
|
weekly: {
|
|
'total_kms': 18.4,
|
|
'breakdown': [
|
|
{'day': '2026-08-19', 'kms': 18.4},
|
|
],
|
|
},
|
|
);
|
|
|
|
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
|
final first = (res['details'] as List).first;
|
|
|
|
// 2026-08-19 is a Wednesday. The chart labels a bar with the first three
|
|
// characters of `day`, so an un-normalised ISO date renders as `202`.
|
|
expect(first.day, 'Wed');
|
|
expect(first.kms, 18.4);
|
|
});
|
|
|
|
test('FAST PATH · a weekday name is passed through untouched', () async {
|
|
stub(
|
|
weekly: {
|
|
'breakdown': [
|
|
{'day': 'Mon', 'kms': 3},
|
|
],
|
|
},
|
|
);
|
|
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
|
expect((res['details'] as List).first.day, 'Mon');
|
|
expect(dailyCalls(), 0);
|
|
});
|
|
|
|
for (final (name, bad) in <(String, Object?)>[
|
|
('absent', null),
|
|
('empty', <dynamic>[]),
|
|
('not a list', 'nope'),
|
|
('entries that are not maps', ['Mon', 'Tue']),
|
|
(
|
|
'an entry with no day',
|
|
[
|
|
{'kms': 4},
|
|
],
|
|
),
|
|
(
|
|
'an entry whose distance will not parse',
|
|
[
|
|
{'day': 'Mon', 'kms': 'lots'},
|
|
],
|
|
),
|
|
]) {
|
|
test('FALLBACK · $name breakdown falls through to seven calls', () async {
|
|
stub(weekly: {'total_kms': 35, if (bad != null) 'breakdown': bad});
|
|
|
|
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
|
final details = res['details'] as List;
|
|
|
|
expect(
|
|
dailyCalls(),
|
|
7,
|
|
reason: 'a series the chart cannot draw is not a series',
|
|
);
|
|
expect(details, hasLength(7));
|
|
expect(
|
|
details.every((d) => d.kms == 5),
|
|
isTrue,
|
|
reason: 'each bar is its own day\'s total_kms',
|
|
);
|
|
});
|
|
}
|
|
|
|
test(
|
|
'FALLBACK · the daily series is labelled and ordered oldest first',
|
|
() async {
|
|
stub();
|
|
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
|
final days = (res['details'] as List).map((d) => d.day).toList();
|
|
|
|
expect(days, hasLength(7));
|
|
expect(days.toSet(), hasLength(7), reason: 'seven distinct weekdays');
|
|
expect(
|
|
days.last,
|
|
const [
|
|
'Mon',
|
|
'Tue',
|
|
'Wed',
|
|
'Thu',
|
|
'Fri',
|
|
'Sat',
|
|
'Sun',
|
|
][DateTime.now().weekday - 1],
|
|
reason: 'the last bar is today',
|
|
);
|
|
},
|
|
);
|
|
|
|
test('FALLBACK · the server\'s weekly total is not overruled', () async {
|
|
// Seven daily figures are a reconstruction. The weekly number the backend
|
|
// computed is the one that stands.
|
|
stub(weekly: {'total_kms': 99}, dailyKm: 5);
|
|
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
|
expect(res['total_kms'], 99);
|
|
});
|
|
|
|
test(
|
|
'FALLBACK · with no weekly total, the reconstruction stands in',
|
|
() async {
|
|
stub(weekly: {'total_kms': 0}, dailyKm: 5);
|
|
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
|
expect(res['total_kms'], 35);
|
|
},
|
|
);
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 3b · THE TWO LIFECYCLE WRITES ACTUALLY LEAVE THE PHONE
|
|
//
|
|
// The defect these pin: the map sheet's **I've arrived** control never called
|
|
// the arrival API. `updateArrivedStatus` was reachable from `homepage.dart`
|
|
// and nowhere else, so on the logistics line — where the map sheet is the
|
|
// only arrival control — `reached` never fired, the local record was never
|
|
// written, and the rung went from ACCEPTED straight to whatever came next.
|
|
//
|
|
// Nothing raised. A transition that is never attempted looks exactly like one
|
|
// that succeeded and was not reflected, which is why this was read as a
|
|
// status-mapping bug for as long as it was.
|
|
//
|
|
// These assert the wire, not the widget: the endpoint, the identifier in the
|
|
// path, and the body — with the geofence bypass ON, which is the state the
|
|
// build ships in and the thing that was suspected of eating the call.
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('3b · the arrival and pickup writes reach the network', () {
|
|
late List<http.Request> sent;
|
|
|
|
void stub() {
|
|
sent = <http.Request>[];
|
|
MilerApi.client = MockClient((req) async {
|
|
sent.add(req);
|
|
return http.Response(
|
|
jsonEncode({'success': true, 'data': {}}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
}
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
stub();
|
|
});
|
|
tearDown(() => MilerApi.client = http.Client());
|
|
|
|
http.Request only(String fragment) {
|
|
final hits = sent.where((r) => r.url.path.contains(fragment)).toList();
|
|
expect(
|
|
hits,
|
|
hasLength(1),
|
|
reason:
|
|
'expected exactly one request to …/$fragment, sent: '
|
|
'${sent.map((r) => r.url.path).toList()}',
|
|
);
|
|
return hits.single;
|
|
}
|
|
|
|
test('ARRIVED · endpoint, path id and body', () async {
|
|
await MilerApi.reached(4211, lat: 11.0168, lon: 76.9558);
|
|
|
|
final req = only('reached');
|
|
expect(req.method, 'POST');
|
|
// The **booking** id, in the path. Not the assignment id — that one keys
|
|
// accept/reject only — and not a consignment, which does not exist yet.
|
|
expect(req.url.path, endsWith('/miler/bookings/4211/reached'));
|
|
expect(jsonDecode(req.body), {'latitude': 11.0168, 'longitude': 76.9558});
|
|
});
|
|
|
|
test(
|
|
'ARRIVED · a fix-less arrival still posts, with no coordinates',
|
|
() async {
|
|
// The body omits what it does not have rather than sending zeros, which
|
|
// would be a claim about where the rider was standing.
|
|
await MilerApi.reached(4211);
|
|
expect(jsonDecode(only('reached').body), isEmpty);
|
|
},
|
|
);
|
|
|
|
test('PICKED · endpoint, path id and body', () async {
|
|
await MilerApi.pickupComplete(4211, lat: 11.0168, lon: 76.9558);
|
|
|
|
final req = only('pickup-complete');
|
|
expect(req.method, 'POST');
|
|
expect(req.url.path, endsWith('/miler/bookings/4211/pickup-complete'));
|
|
expect(jsonDecode(req.body), {'latitude': 11.0168, 'longitude': 76.9558});
|
|
expect(
|
|
req.headers['Idempotency-Key'],
|
|
isNotNull,
|
|
reason:
|
|
'the pivot is the one write a dropped acknowledgement must not '
|
|
'be able to run twice',
|
|
);
|
|
});
|
|
|
|
test(
|
|
'the provider routes ARRIVED to reached, not to anything else',
|
|
() async {
|
|
await UpdatePickupProvider().updatePickup({
|
|
'pickupid': 4211,
|
|
'orderheaderid': 77,
|
|
'orderstatus': 'arrived',
|
|
'riderslat': '11.016800',
|
|
'riderslon': '76.955800',
|
|
});
|
|
|
|
final req = only('reached');
|
|
// `pickupid` IS the booking id — see `ApiConfig.pickupFromBooking`, which
|
|
// maps `bookingid` onto it. This pins that, because the provider picks
|
|
// the path id off the payload and a change there would be silent.
|
|
expect(req.url.path, endsWith('/miler/bookings/4211/reached'));
|
|
expect(jsonDecode(req.body), {
|
|
'latitude': 11.0168,
|
|
'longitude': 76.9558,
|
|
});
|
|
},
|
|
);
|
|
|
|
test('the provider routes PICKED to pickup-complete', () async {
|
|
await UpdatePickupProvider().updatePickup({
|
|
'pickupid': 4211,
|
|
'orderheaderid': 77,
|
|
'orderstatus': 'picked',
|
|
'riderslat': '11.016800',
|
|
'riderslon': '76.955800',
|
|
});
|
|
|
|
expect(
|
|
only('pickup-complete').url.path,
|
|
endsWith('/miler/bookings/4211/pickup-complete'),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'a missing booking id refuses the call, it does not post to zero',
|
|
() async {
|
|
// ── The silent one ──
|
|
//
|
|
// The provider resolved its path id with `pickupid ?? orderheaderid`, and
|
|
// `??` catches only null. Every caller builds the payload with
|
|
// `int.tryParse(...) ?? 0`, so a row without a booking id arrived as
|
|
// **0** — not null — the fallback never fired, and the app posted to
|
|
// `/miler/bookings/0/reached`.
|
|
//
|
|
// Nothing complained on the phone: arrival is the rider's own report and
|
|
// is deliberately not gated on the write landing, so his rung advanced
|
|
// and the row looked healthy. The hub was told about booking zero, and
|
|
// the console showed no arrival — which is exactly how this was reported.
|
|
final res = await UpdatePickupProvider().updatePickup({
|
|
'pickupid': 0,
|
|
'orderheaderid': 0,
|
|
'orderstatus': 'arrived',
|
|
'riderslat': '11.016800',
|
|
'riderslon': '76.955800',
|
|
});
|
|
|
|
expect(sent, isEmpty, reason: 'booking 0 is not a booking');
|
|
expect(res?['status'], isFalse);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'a real id still posts, and zero on the other key is ignored',
|
|
() async {
|
|
await UpdatePickupProvider().updatePickup({
|
|
'pickupid': 0,
|
|
'orderheaderid': 77,
|
|
'orderstatus': 'arrived',
|
|
'riderslat': '11.016800',
|
|
'riderslon': '76.955800',
|
|
});
|
|
expect(
|
|
only('reached').url.path,
|
|
endsWith('/miler/bookings/77/reached'),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('the geofence bypass does not stop the write leaving', () async {
|
|
// The suspicion this rules out. `_checkGeofence` returns true early when
|
|
// enforcement is off and its success branch contains nothing else — so
|
|
// the bypass skips the distance *rejection* and nothing more.
|
|
//
|
|
// Asserted through the provider rather than by reading the flag, because
|
|
// "the request went out with the fence off" is the claim, and the flag is
|
|
// only evidence for it.
|
|
expect(kBypassGeofenceForTesting, isTrue);
|
|
|
|
await UpdatePickupProvider().updatePickup({
|
|
'pickupid': 4211,
|
|
'orderstatus': 'arrived',
|
|
'riderslat': '11.016800',
|
|
'riderslon': '76.955800',
|
|
});
|
|
|
|
expect(sent, isNotEmpty, reason: 'the bypass swallowed the request');
|
|
expect(only('reached').url.path, contains('/4211/reached'));
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 3c · THE PICKUP MILESTONE IS NOT THE DELIVERY STATUS
|
|
//
|
|
// The backend's booking lifecycle, confirmed by them and with no Arrived rung
|
|
// anywhere in it:
|
|
//
|
|
// pending → miler_assigned → pickup_scheduled → converted_to_consignment
|
|
// → out_for_delivery / active → delivered
|
|
//
|
|
// Three of those words carry the **pickup** milestone and all three parsed as
|
|
// `unknown`: `pickup_scheduled`, `converted_to_consignment` and `picked_up`.
|
|
// So the one field the pickup UI reads had nothing in it, and `active` — a
|
|
// *delivery* state, which does parse — won by default. That is the second
|
|
// half of "Picked shows as Active"; the first was the app writing `active`
|
|
// onto the rung itself.
|
|
//
|
|
// Nothing about the backend's meanings is changed here. `active` still means
|
|
// active, and the delivery surfaces still read it that way. What is fixed is
|
|
// that the pickup surfaces resolve the *pickup* fact and it outranks a
|
|
// delivery state that came after it.
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('3c · the pickup rung, derived from authoritative facts', () {
|
|
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
|
|
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
|
|
Map<String, dynamic> row(String status) => {
|
|
'orderid': 'B1',
|
|
'pickupid': 'B1',
|
|
'sourcename': 'Vidhya Kitchen',
|
|
'orderstatus': status,
|
|
};
|
|
|
|
StopStatus rung(
|
|
String status, {
|
|
Set<String> collected = const {},
|
|
Set<String> arrived = const {},
|
|
Set<String> released = const {},
|
|
Set<String> accepted = const {'B1'},
|
|
}) => MilkRun.stageOf(
|
|
row(status),
|
|
acceptedIds: accepted,
|
|
collectedIds: collected,
|
|
arrivedIds: arrived,
|
|
outForDeliveryIds: released,
|
|
);
|
|
|
|
test('the backend\'s pickup words parse at all', () {
|
|
// Each of these was StopStatus.unknown.
|
|
expect(stopStatusFromRaw('pickup_scheduled'), StopStatus.accepted);
|
|
expect(stopStatusFromRaw('converted_to_consignment'), StopStatus.picked);
|
|
expect(stopStatusFromRaw('picked_up'), StopStatus.picked);
|
|
});
|
|
|
|
test('pickup_scheduled, nothing else → Accepted', () {
|
|
expect(rung('pickup_scheduled'), StopStatus.accepted);
|
|
});
|
|
|
|
test('pickup_scheduled + an arrival fact → Arrived', () {
|
|
expect(
|
|
rung('pickup_scheduled', arrived: {'B1'}),
|
|
StopStatus.arrived,
|
|
reason:
|
|
'the arrival is the rider\'s own record; nothing outranks it '
|
|
'here because nothing further along has happened',
|
|
);
|
|
});
|
|
|
|
test('converted_to_consignment → Picked', () {
|
|
expect(rung('converted_to_consignment'), StopStatus.picked);
|
|
});
|
|
|
|
test('picked_up → Picked', () {
|
|
expect(rung('picked_up'), StopStatus.picked);
|
|
});
|
|
|
|
test('active + pickup evidence → Picked on the pickup UI', () {
|
|
// The case the whole separation exists for. The delivery lifecycle has
|
|
// moved on; the pickup milestone did not un-happen.
|
|
expect(rung('active', collected: {'B1'}), StopStatus.picked);
|
|
});
|
|
|
|
test('out_for_delivery + pickup evidence → Picked on the pickup UI', () {
|
|
expect(rung('out_for_delivery', collected: {'B1'}), StopStatus.picked);
|
|
});
|
|
|
|
test('delivered keeps its own rung, not the pickup one', () {
|
|
// A delivered stop is finished. The pickup milestone is history, and the
|
|
// rung a rider is shown is the one that is current.
|
|
expect(rung('delivered', collected: {'B1'}), StopStatus.delivered);
|
|
});
|
|
|
|
test('active WITHOUT pickup evidence stays active', () {
|
|
// The line the brief drew: do not infer Picked merely because a booking
|
|
// is Active. Without a collection fact there is nothing to infer from.
|
|
//
|
|
// `accepted` is emptied here on purpose: the local accept store is
|
|
// checked below `active` in the ladder, so leaving it in would answer
|
|
// `accepted` and the test would pass for the wrong reason.
|
|
expect(rung('active', accepted: const {}), StopStatus.active);
|
|
});
|
|
|
|
test('the delivery resolver still reads active as active', () {
|
|
// Nothing about the backend's meaning is being renamed.
|
|
expect(stopStatusFromRaw('active'), StopStatus.active);
|
|
expect(stopStatusFromRaw('out_for_delivery'), StopStatus.outForDelivery);
|
|
});
|
|
|
|
test('an arrival record cannot walk a picked stop backwards', () {
|
|
expect(
|
|
rung('converted_to_consignment', arrived: {'B1'}, collected: {'B1'}),
|
|
StopStatus.picked,
|
|
);
|
|
expect(
|
|
rung('out_for_delivery', arrived: {'B1'}, released: {'B1'}),
|
|
StopStatus.outForDelivery,
|
|
);
|
|
expect(rung('delivered', arrived: {'B1'}), StopStatus.delivered);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 3d · START DELIVERY IS THE ONLY THING THAT MAKES A ROUND ACTIVE
|
|
//
|
|
// The one rider action that moves `Collected_By_Miler → Out_for_Delivery`.
|
|
// Not pickup-complete, not opening the Deliveries tab, not navigation, not
|
|
// availability — all of which have at some point been mistaken for it.
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('3d · start-delivery, and what must not call it', () {
|
|
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: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
});
|
|
tearDown(() => MilerApi.client = http.Client());
|
|
|
|
test('it is keyed on the consignment, with no body', () async {
|
|
await MilerApi.startDelivery('C-4211');
|
|
|
|
final req = sent.single;
|
|
expect(req.method, 'POST');
|
|
// The **consignment**, not the booking. They are different sequences and
|
|
// posting one where the other belongs is a 404 the rider is shown as
|
|
// success.
|
|
expect(
|
|
req.url.path,
|
|
endsWith('/miler/consignments/C-4211/start-delivery'),
|
|
);
|
|
expect(
|
|
jsonDecode(req.body),
|
|
isEmpty,
|
|
reason:
|
|
'the contract takes no parameters; inventing one is how a '
|
|
'handler starts rejecting a call that used to work',
|
|
);
|
|
expect(req.headers['Idempotency-Key'], isNotNull);
|
|
});
|
|
|
|
test('pickup-complete does not start a round by itself', () async {
|
|
await MilerApi.pickupComplete(4211, lat: 11.0, lon: 76.9);
|
|
|
|
expect(
|
|
sent.where((r) => r.url.path.contains('start-delivery')),
|
|
isEmpty,
|
|
reason:
|
|
'Picked has to stay visible until the rider sets off; a '
|
|
'pickup that starts its own round erases the rung',
|
|
);
|
|
expect(sent.single.url.path, contains('pickup-complete'));
|
|
});
|
|
|
|
test('availability is its own call and touches no lifecycle', () async {
|
|
await MilerApi.setAvailability('On_Pickup');
|
|
|
|
final req = sent.single;
|
|
expect(req.url.path, contains('/miler/availability'));
|
|
expect(
|
|
req.url.path,
|
|
isNot(contains('bookings')),
|
|
reason: 'availability is a fact about the RIDER',
|
|
);
|
|
expect(req.url.path, isNot(contains('consignments')));
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 4 · ARRIVED PRECEDENCE
|
|
//
|
|
// The local store exists only because `reached` did not persist. It is the
|
|
// weakest record in the app and its one rule is that it must never compete
|
|
// with the server: it may speak where the server has said nothing further
|
|
// along, and nowhere else.
|
|
//
|
|
// The rule used to be three exclusions — not complete, not cancelled, not
|
|
// rejected — and `isWorkComplete` is line-dependent: on a milk run it means
|
|
// *delivered or cancelled* only. So `picked`, `outForDelivery` and
|
|
// `deliveryArrived` all passed it, and a stale local record could walk a stop
|
|
// the hub knew was on the road back to a rung the rider left an hour ago.
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('4 · the local ARRIVED record never outranks the server', () {
|
|
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
|
|
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
|
|
/// The shipped rule, reached through the same public surface `_fetchQueues`
|
|
/// uses — the status the server reported, parsed.
|
|
bool mayShow(String serverStatus) =>
|
|
HomepageArrivalPrecedence.localArrivalMayShow(
|
|
stopStatusFromRaw(serverStatus),
|
|
);
|
|
|
|
test('it may speak where the server has nothing further along', () {
|
|
for (final said in const [
|
|
'new',
|
|
'assigned',
|
|
'accepted',
|
|
'arrived',
|
|
'this is not a status the app knows',
|
|
]) {
|
|
expect(
|
|
mayShow(said),
|
|
isTrue,
|
|
reason: '"$said" is not a rung past arrival',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('it is silent once the server has moved the stop on', () {
|
|
// Every one of these passed the old three-exclusion test on a milk run.
|
|
for (final said in const [
|
|
'picked',
|
|
'outfordelivery',
|
|
'deliveryarrived',
|
|
'delivered',
|
|
'cancelled',
|
|
'rejected',
|
|
]) {
|
|
expect(
|
|
mayShow(said),
|
|
isFalse,
|
|
reason:
|
|
'"$said" is the server\'s own answer — a local record that '
|
|
'overwrote it would walk the stop backwards',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the rung the map path writes on arrival is ARRIVED, not ACTIVE', () {
|
|
// ── Where "Picked" became "Active" ──
|
|
//
|
|
// `_startPickupNavigation` wrote `orderstatus = 'active'` onto the row.
|
|
// Two separate facts were going into one field: *which stop is live*,
|
|
// which `_activePickupOrderId` already carries and is what the LIVE mark
|
|
// reads, and *how far up the pickup ladder this stop is*.
|
|
//
|
|
// `active` was never a server state either. The provider maps it to
|
|
// `setAvailability('On_Pickup')` — a fact about the **rider** — because
|
|
// there is no rider endpoint that sets a booking Active;
|
|
// `Pickup_Scheduled` is system-set. So the word was app-generated and it
|
|
// outranked the real rung on every screen reading `orderstatus`.
|
|
//
|
|
// Pinned on the resolver, not on any widget's text.
|
|
expect(
|
|
stopStatusFromRaw('active'),
|
|
StopStatus.active,
|
|
reason: 'active parses as its own thing and is not a pickup rung',
|
|
);
|
|
expect(stopStatusFromRaw('active'), isNot(StopStatus.picked));
|
|
expect(stopStatusFromRaw('active').isPicked, isFalse);
|
|
expect(
|
|
stopStatusFromRaw('active').isFinishedPickup,
|
|
isFalse,
|
|
reason: 'an active stop is not a collected one',
|
|
);
|
|
});
|
|
|
|
test('the server\'s own arrival word resolves to the arrived rung', () {
|
|
for (final said in const [
|
|
'Arrived_At_Pickup',
|
|
'arrived_at_pickup',
|
|
'ARRIVEDATPICKUP',
|
|
'arrived',
|
|
]) {
|
|
expect(
|
|
stopStatusFromRaw(said),
|
|
StopStatus.arrived,
|
|
reason: '"$said" must land on the rung the rider was shown',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('picked and active are distinct rungs, and stay distinct', () {
|
|
// The explicit regression: `Picked != Active`.
|
|
expect(stopStatusFromRaw('picked'), StopStatus.picked);
|
|
expect(stopStatusFromRaw('picked'), isNot(stopStatusFromRaw('active')));
|
|
expect(stopStatusFromRaw('picked').isPicked, isTrue);
|
|
});
|
|
|
|
test('and it is silent on a parcel route too', () {
|
|
// `isWorkComplete` differs by line, which is exactly what made the old
|
|
// blacklist leak. The rule must not depend on which line is running.
|
|
ServiceProfile.setActive(ServiceProfile.parcel);
|
|
expect(mayShow('picked'), isFalse);
|
|
expect(mayShow('accepted'), isTrue);
|
|
});
|
|
});
|
|
}
|