339 lines
13 KiB
Dart
339 lines
13 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'package:miler/Models/stop_status.dart';
|
|
import 'package:miler/data/api_config.dart';
|
|
import 'package:miler/data/api_status.dart';
|
|
import 'package:miler/data/accepted_store.dart';
|
|
import 'package:miler/data/assignment_lookup.dart';
|
|
import 'package:miler/data/service_profile.dart';
|
|
import 'package:miler/data/work_domain.dart';
|
|
import 'package:miler/views/Dashboard/home/trip.dart';
|
|
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// THE BACKEND CONTRACT, AS THIS APP READS IT
|
|
///
|
|
/// Every status `GET /miler/bookings` can carry, taken from the API reference
|
|
/// and the flow doc in the console repo rather than from what the app happened
|
|
/// to have seen. The list is the contract; the assertions are this app's
|
|
/// reading of it.
|
|
///
|
|
/// Three defects these pin:
|
|
///
|
|
/// • **`Out_for_Delivery` was not in [BookingStatus].** `pickup-complete`
|
|
/// releases a hyperlocal booking straight to it — which is every DailyGrubs
|
|
/// run — so a bag already in the rider's box parsed as `unknown`, translated
|
|
/// to the empty string, and read as *undecided*. The local collected record
|
|
/// hid it on the phone that did the pickup and nowhere else: after a restart
|
|
/// or on a second device, collected work sat on Home as pending.
|
|
/// • **An unmapped status write was reported as success.** Nothing left the
|
|
/// phone and the caller was told it had landed.
|
|
/// • **The hub's solved stop order was discarded.** `step` lives on the
|
|
/// assignment row; the app read those rows for their ids and dropped it.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
|
|
/// Exactly the strings the backend sends, in the backend's own casing —
|
|
/// including `Out_for_Delivery`, whose lower-case `f` differs from the
|
|
/// consignment enum's `Out_For_Delivery`.
|
|
const backendBookingStatuses = <String>[
|
|
'Created',
|
|
'Pending_Pickup',
|
|
'Miler_Assigned',
|
|
'Pickup_Scheduled',
|
|
'Picked_Up',
|
|
'Converted_To_Consignment',
|
|
'Out_for_Delivery',
|
|
'Delivered',
|
|
'Cancelled',
|
|
];
|
|
|
|
group('every status the backend sends is typed', () {
|
|
test('none of them fall through to unknown', () {
|
|
for (final raw in backendBookingStatuses) {
|
|
expect(
|
|
BookingStatus.parse(raw),
|
|
isNot(BookingStatus.unknown),
|
|
reason: '$raw is in the contract and must parse',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('and each one survives the translation to the row vocabulary', () {
|
|
// `legacyStatusFromNew` is the seam between the v1 enum and the lowercase
|
|
// vocabulary every row, card and filter in this app reads. A status that
|
|
// parses but translates to something `stopStatusFromRaw` cannot read is
|
|
// the same bug one layer further on.
|
|
for (final raw in backendBookingStatuses) {
|
|
final legacy = ApiConfig.legacyStatusFromNew(raw);
|
|
expect(
|
|
stopStatusFromRaw(legacy),
|
|
isNot(StopStatus.unknown),
|
|
reason: '$raw translated to "$legacy", which the app cannot read',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('both casings of out-for-delivery land on the same rung', () {
|
|
expect(
|
|
BookingStatus.parse('Out_for_Delivery'),
|
|
BookingStatus.outForDelivery,
|
|
);
|
|
expect(
|
|
BookingStatus.parse('Out_For_Delivery'),
|
|
BookingStatus.outForDelivery,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('collected is a fact about the backend, not about this device', () {
|
|
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
|
|
|
|
test(
|
|
'a released hyperlocal booking is delivery work with no local record',
|
|
() {
|
|
// The restart case: `collectedIds` is empty because this install never
|
|
// performed the pickup. The status alone has to carry it.
|
|
final stop = {
|
|
'orderid': 'A1',
|
|
'type': 'pickup',
|
|
'orderstatus': ApiConfig.legacyStatusFromNew('Out_for_Delivery'),
|
|
};
|
|
|
|
expect(WorkBoundary.pickupComplete(stop), isTrue);
|
|
expect(WorkBoundary.domainOf(stop), WorkDomain.delivery);
|
|
},
|
|
);
|
|
|
|
test('and a delivered one is finished, not pending', () {
|
|
final stop = {
|
|
'orderid': 'A1',
|
|
'type': 'pickup',
|
|
'orderstatus': ApiConfig.legacyStatusFromNew('Delivered'),
|
|
};
|
|
|
|
expect(WorkBoundary.domainOf(stop), WorkDomain.closed);
|
|
});
|
|
|
|
test('an assignment the rider has not acted on is still Home\'s', () {
|
|
for (final raw in ['Created', 'Pending_Pickup', 'Miler_Assigned']) {
|
|
final stop = {
|
|
'orderid': 'A1',
|
|
'orderstatus': ApiConfig.legacyStatusFromNew(raw),
|
|
};
|
|
expect(WorkBoundary.domainOf(stop), WorkDomain.pickup, reason: raw);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('the rungs come in order', () {
|
|
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
|
|
|
|
test('accepting does not skip the arrival rung', () {
|
|
// The reported bug: accept a stop, select it again, and the bar offered
|
|
// **Mark as Picked** for a kitchen the rider had not reached.
|
|
//
|
|
// `Pickup_Scheduled` is what the backend writes on accept, and it was
|
|
// translated to `active` — which `stopStateOf` reads as *the rider is on
|
|
// the stop*, ahead of the local accepted record. So the queue's own
|
|
// answer promoted every accepted stop straight past arrival, and
|
|
// `reached` — the one rung with a real endpoint behind it — could never
|
|
// be sent.
|
|
final stop = {
|
|
'orderid': 'A1',
|
|
'orderstatus': ApiConfig.legacyStatusFromNew('Pickup_Scheduled'),
|
|
};
|
|
|
|
expect(
|
|
stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}),
|
|
StopState.accepted,
|
|
reason: 'accepted, not on the stop',
|
|
);
|
|
});
|
|
|
|
test('and arriving is its own rung, not "active"', () {
|
|
// ── The two used to be one, and it cost the rider his arrival ──
|
|
//
|
|
// `stopStateOf` folded `arrived` into [StopState.active], so the Home row
|
|
// had no arrived to render and the live chip printed the literal word
|
|
// **Active** the moment he slid *confirm arrival*. The rung had moved,
|
|
// `/reached` had fired and the local record was written — and the one
|
|
// word on screen said something else, which is indistinguishable from
|
|
// the arrival not registering.
|
|
//
|
|
// They are different facts: arrived is a rung on the pickup ladder, and
|
|
// active is the backend's word for a delivery under way.
|
|
final stop = {'orderid': 'A1', 'orderstatus': 'arrived'};
|
|
expect(
|
|
stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}),
|
|
StopState.arrived,
|
|
);
|
|
expect(
|
|
stopStateOf(
|
|
{'orderid': 'A1', 'orderstatus': 'active'},
|
|
acceptedIds: const {},
|
|
rejectedIds: const {},
|
|
),
|
|
StopState.active,
|
|
reason: 'and active still means active',
|
|
);
|
|
});
|
|
|
|
test('the local accept record reads the same rung as the server\'s', () {
|
|
// Optimistic accept, before the queue has caught up: both paths must
|
|
// land on the same rung or the bar changes its offer under him.
|
|
final stop = {'orderid': 'A1', 'orderstatus': 'assigned'};
|
|
expect(
|
|
stopStateOf(stop, acceptedIds: const {'A1'}, rejectedIds: const {}),
|
|
StopState.accepted,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('an unknown status is never a settled one', () {
|
|
test('a future booking status reads as undecided, not as finished', () {
|
|
const future = 'Rescheduled_By_Hub';
|
|
expect(BookingStatus.parse(future), BookingStatus.unknown);
|
|
expect(BookingStatus.parse(future).isCollected, isFalse);
|
|
|
|
final stop = {
|
|
'orderid': 'A1',
|
|
'orderstatus': ApiConfig.legacyStatusFromNew(future),
|
|
};
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
expect(
|
|
WorkBoundary.domainOf(stop),
|
|
WorkDomain.pickup,
|
|
reason: 'wrong-but-safe: it stays where the rider can still act on it',
|
|
);
|
|
expect(WorkBoundary.isClosed(stop), isFalse);
|
|
});
|
|
|
|
test('a future assignment status is not settled either', () {
|
|
expect(AssignmentStatus.parse('Escalated'), AssignmentStatus.unknown);
|
|
expect(AssignmentStatus.unknown.isSettled, isFalse);
|
|
expect(AssignmentStatus.unknown.isLive, isFalse);
|
|
expect(AssignmentStatus.unknown.needsDecision, isFalse);
|
|
});
|
|
|
|
test('a future consignment status is not delivered', () {
|
|
expect(
|
|
ConsignmentStatus.parse('Held_At_Customs'),
|
|
ConsignmentStatus.unknown,
|
|
);
|
|
expect(ConsignmentStatus.unknown.isDelivered, isFalse);
|
|
});
|
|
});
|
|
|
|
group('a write this app cannot map is a failure, not a success', () {
|
|
test('the unmapped branch reports failure and sends nothing', () async {
|
|
// No network stub: reaching one would itself be the bug. The branch must
|
|
// refuse before any request is built.
|
|
final res = await UpdatePickupProvider().updatePickup({
|
|
'pickupid': 1,
|
|
'orderstatus': 'teleported',
|
|
});
|
|
|
|
expect(res, isNotNull);
|
|
expect(res!['status'], isFalse);
|
|
expect(res['message'], contains('teleported'));
|
|
});
|
|
});
|
|
|
|
group('the consignment id survives the pivot', () {
|
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
|
|
|
test('it is recorded at pickup-complete and read back at the door', () async {
|
|
// The reported failure: press **Delivered** at a customer's door and the
|
|
// app answers "could not update — check your connection", with no request
|
|
// ever made.
|
|
//
|
|
// `deliver` and `skip` key on the CONSIGNMENT, which only exists after
|
|
// `pickup-complete` creates it. That response was wrapped and discarded,
|
|
// and the row the rider works from on Deliveries is a snapshot taken
|
|
// *before* the conversion — so nothing on the device knew what to
|
|
// deliver, and the refusal came from the app, not the network.
|
|
await rememberConsignmentId('A1', 'CN-9001');
|
|
|
|
expect((await getConsignmentIds())['A1'], 'CN-9001');
|
|
});
|
|
|
|
test('and is dropped once the stop is finished', () async {
|
|
await rememberConsignmentId('A1', 'CN-9001');
|
|
await forgetConsignmentIds(['A1']);
|
|
|
|
expect((await getConsignmentIds()).containsKey('A1'), isFalse);
|
|
});
|
|
|
|
test('an empty id is not worth recording', () async {
|
|
await rememberConsignmentId('A1', '');
|
|
await rememberConsignmentId('', 'CN-9001');
|
|
|
|
expect(await getConsignmentIds(), isEmpty);
|
|
});
|
|
});
|
|
|
|
group('the hub\'s solved stop order is kept', () {
|
|
test('step is read from the assignment row', () {
|
|
final index = AssignmentLookup.buildStepIndex([
|
|
{
|
|
'bookingid': 118,
|
|
'bookingassignmentid': 1,
|
|
'step': 2,
|
|
'assignmentstatus': 'Accepted',
|
|
},
|
|
{
|
|
'bookingid': 119,
|
|
'bookingassignmentid': 2,
|
|
'step': 1,
|
|
'assignmentstatus': 'Assigned',
|
|
},
|
|
]);
|
|
|
|
expect(index['118'], 2);
|
|
expect(index['119'], 1);
|
|
});
|
|
|
|
test('step 0 means unsequenced and is not stored as a position', () {
|
|
// `step: 0` sorts to the end in `Trip.sortStops`; storing it would make an
|
|
// unsequenced stop look like it had been solved into first place.
|
|
final index = AssignmentLookup.buildStepIndex([
|
|
{
|
|
'bookingid': 120,
|
|
'bookingassignmentid': 3,
|
|
'step': 0,
|
|
'assignmentstatus': 'Assigned',
|
|
},
|
|
]);
|
|
|
|
expect(index.containsKey('120'), isFalse);
|
|
});
|
|
|
|
test('a reassigned booking takes the live assignment\'s sequence', () {
|
|
// Same rule as the id index: newest first, but an actionable row beats a
|
|
// newer terminal one, so the two indexes cannot describe different rows.
|
|
final rows = [
|
|
{
|
|
'bookingid': 118,
|
|
'bookingassignmentid': 9,
|
|
'step': 5,
|
|
'assignmentstatus': 'Rejected',
|
|
},
|
|
{
|
|
'bookingid': 118,
|
|
'bookingassignmentid': 10,
|
|
'step': 3,
|
|
'assignmentstatus': 'Accepted',
|
|
},
|
|
];
|
|
|
|
expect(AssignmentLookup.buildStepIndex(rows)['118'], 3);
|
|
expect(AssignmentLookup.buildIndex(rows)['118'], 10);
|
|
});
|
|
});
|
|
}
|