production
This commit is contained in:
865
test/miler_contract_regression_test.dart
Normal file
865
test/miler_contract_regression_test.dart
Normal file
@@ -0,0 +1,865 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user