175 lines
6.9 KiB
Dart
175 lines
6.9 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:miler/data/consignment_state.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// BOOKING WORDS ARE NOT CONSIGNMENT WORDS
|
|
///
|
|
/// The delivery half of the lifecycle lives on the consignment, and the app
|
|
/// spent a long time inferring it from the booking — whose story ends at
|
|
/// `Converted_To_Consignment` and never mentions delivery at all. These pin
|
|
/// the vocabulary boundary and the one state `deliver` accepts.
|
|
///
|
|
/// Root cause this suite exists to prevent, verified against the live API on
|
|
/// 2026-08-21: `POST /miler/consignments/:id/deliver` answers
|
|
/// `400 consignment is not out for delivery` for **two opposite** situations —
|
|
/// "still at the hub" and "already delivered". Consignment 34's own history
|
|
/// read `Out_for_Delivery` then `Delivered`; the app showed the rider an error
|
|
/// for work he had completed an hour earlier, because the only state it could
|
|
/// see was the booking's terminal word.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
group('parsing', () {
|
|
test('the consignment vocabulary is understood in every spelling', () {
|
|
expect(
|
|
consignmentStateFromRaw('Out_for_Delivery'),
|
|
ConsignmentState.outForDelivery,
|
|
);
|
|
expect(
|
|
consignmentStateFromRaw('out for delivery'),
|
|
ConsignmentState.outForDelivery,
|
|
);
|
|
expect(
|
|
consignmentStateFromRaw('OUTFORDELIVERY'),
|
|
ConsignmentState.outForDelivery,
|
|
);
|
|
expect(consignmentStateFromRaw('Delivered'), ConsignmentState.delivered);
|
|
expect(
|
|
consignmentStateFromRaw('Inwarded_at_Hub'),
|
|
ConsignmentState.inwardedAtHub,
|
|
);
|
|
});
|
|
|
|
test('BOOKING words are not consignment states', () {
|
|
// The exact confusion that produced the bug: these are booking-side
|
|
// words, and none of them may resolve to a deliverable consignment.
|
|
for (final booking in const [
|
|
'Converted_To_Consignment',
|
|
'Picked_Up',
|
|
'Pickup_Scheduled',
|
|
'Miler_Assigned',
|
|
'Pending_Pickup',
|
|
]) {
|
|
final state = consignmentStateFromRaw(booking);
|
|
expect(
|
|
state,
|
|
ConsignmentState.unknown,
|
|
reason:
|
|
'"$booking" is a BOOKING status; it must not name a '
|
|
'consignment state',
|
|
);
|
|
expect(state.isDeliverable, isFalse);
|
|
}
|
|
});
|
|
|
|
test('an unknown status is never deliverable and never finished', () {
|
|
final s = consignmentStateFromRaw('Teleported');
|
|
expect(s, ConsignmentState.unknown);
|
|
expect(s.isDeliverable, isFalse);
|
|
expect(s.isDelivered, isFalse);
|
|
expect(s.isClosed, isFalse);
|
|
expect(s.awaitsHub, isFalse);
|
|
expect(ConsignmentGate.gateFor(s), DeliverGate.unknown);
|
|
});
|
|
|
|
test('empty and null are unknown, not deliverable', () {
|
|
expect(consignmentStateFromRaw(null).isDeliverable, isFalse);
|
|
expect(consignmentStateFromRaw('').isDeliverable, isFalse);
|
|
});
|
|
});
|
|
|
|
group('the gate', () {
|
|
test('only Out_for_Delivery may be delivered', () {
|
|
for (final s in ConsignmentState.values) {
|
|
expect(
|
|
s.isDeliverable,
|
|
s == ConsignmentState.outForDelivery,
|
|
reason:
|
|
'${s.name} must ${s == ConsignmentState.outForDelivery ? '' : 'not '}'
|
|
'be deliverable',
|
|
);
|
|
}
|
|
expect(
|
|
ConsignmentGate.gateFor(ConsignmentState.outForDelivery),
|
|
DeliverGate.deliverable,
|
|
);
|
|
});
|
|
|
|
test('an already-delivered consignment reconciles, it does not error', () {
|
|
expect(
|
|
ConsignmentGate.gateFor(ConsignmentState.delivered),
|
|
DeliverGate.alreadyDelivered,
|
|
reason: 'the work is done server-side; the device is what is behind',
|
|
);
|
|
});
|
|
|
|
test('a genuine hub hold stays blocked — the logistics guard', () {
|
|
// The guard the brief requires be kept: a consignment inside the hub's
|
|
// half of the network is not this rider's to hand over.
|
|
//
|
|
// `Created` is deliberately NOT in this list — see the test below.
|
|
for (final s in const [
|
|
ConsignmentState.inwardedAtHub,
|
|
ConsignmentState.tripsheetLoaded,
|
|
ConsignmentState.inTransit,
|
|
]) {
|
|
expect(s.awaitsHub, isTrue, reason: '${s.name} is hub-side');
|
|
expect(s.isDeliverable, isFalse);
|
|
expect(ConsignmentGate.gateFor(s), DeliverGate.awaitingHub);
|
|
}
|
|
});
|
|
|
|
test('`Created` is the rider\'s own parcel, not a hub hold', () {
|
|
// The bug this pins: `Created` was in the hub list, so sliding **Start
|
|
// ride** on a freshly converted consignment answered "This parcel is
|
|
// with the hub — it will be delivered from there, not by you" about a
|
|
// bag on the rider's own back, and nothing on that screen cleared it.
|
|
//
|
|
// The backend writes `Created` at the pivot for hub-routed work
|
|
// (`next_action: inward_at_hub`) and holds it there transiently while
|
|
// hyperlocal work is routed. The hub has not taken it in either way.
|
|
const s = ConsignmentState.created;
|
|
expect(s.awaitsHub, isFalse, reason: 'the hub has never seen it');
|
|
expect(s.awaitsHubInward, isTrue);
|
|
expect(ConsignmentGate.gateFor(s), DeliverGate.awaitingInward);
|
|
|
|
// Still not deliverable: `deliver` only accepts `Out_for_Delivery`. The
|
|
// fix changes which sentence the rider is shown, never what is claimed.
|
|
expect(s.isDeliverable, isFalse);
|
|
expect(s.needsRelease, isFalse);
|
|
|
|
// And no other state may drift into the new rung.
|
|
for (final other in ConsignmentState.values) {
|
|
if (other == ConsignmentState.created) continue;
|
|
expect(
|
|
other.awaitsHubInward,
|
|
isFalse,
|
|
reason: '${other.name} is not the pre-inward rung',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('closed work is refused without blaming the rider', () {
|
|
for (final s in const [
|
|
ConsignmentState.cancelled,
|
|
ConsignmentState.returnedToSender,
|
|
]) {
|
|
expect(ConsignmentGate.gateFor(s), DeliverGate.closed);
|
|
expect(s.isDeliverable, isFalse);
|
|
}
|
|
});
|
|
|
|
test('an unreadable state does not block the rider', () async {
|
|
// A failed GET is not evidence of anything. Blocking here would strand a
|
|
// rider at a door because a read timed out; the server stays the judge.
|
|
expect(
|
|
ConsignmentGate.gateFor(ConsignmentState.unknown),
|
|
DeliverGate.unknown,
|
|
);
|
|
// No id is unreadable, and must not throw or hang.
|
|
expect(await ConsignmentGate.stateOf(''), ConsignmentState.unknown);
|
|
expect(await ConsignmentGate.stateOf('0'), ConsignmentState.unknown);
|
|
});
|
|
});
|
|
}
|