358 lines
12 KiB
Dart
358 lines
12 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:miler/Models/stop_status.dart';
|
|
import 'package:miler/data/milk_run.dart';
|
|
import 'package:miler/data/rider_stage.dart';
|
|
import 'package:miler/data/service_profile.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// THE RIDER-FACING LIFECYCLE, END TO END
|
|
///
|
|
/// ```
|
|
/// Pending → Accepted → Arrived → Picked → Active → Delivered
|
|
/// ```
|
|
///
|
|
/// Six stages against a backend lifecycle that has five and no arrival in it.
|
|
/// The gap is not an oversight on their side: arrival is an **event**, written
|
|
/// by `/reached` as a timestamp beside a status that does not move, and asking
|
|
/// them to add a rung would be asking them to model an event as a state.
|
|
///
|
|
/// So the app owes the richer workflow, and these hold it to it. Two resolvers
|
|
/// are exercised, deliberately, because they answer different questions about
|
|
/// the same order:
|
|
///
|
|
/// • [riderStageOf] — where is this order in the rider's day?
|
|
/// • [MilkRun.stageOf] — how far did the *pickup* get?
|
|
///
|
|
/// A stop whose delivery is under way is `Active` on the first and `Picked` on
|
|
/// the second, and both are correct. That separation is the point of the whole
|
|
/// exercise, so it is asserted rather than assumed.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
const id = 'B1';
|
|
Map<String, dynamic> row(String status) => {
|
|
'orderid': id,
|
|
'pickupid': id,
|
|
'sourcename': 'Vidhya Kitchen',
|
|
'orderstatus': status,
|
|
};
|
|
|
|
RiderStage stage(
|
|
String status, {
|
|
Set<String> arrived = const {},
|
|
Set<String> collected = const {},
|
|
Set<String> released = const {},
|
|
Set<String> delivered = const {},
|
|
}) => riderStageOf(
|
|
row(status),
|
|
arrivedIds: arrived,
|
|
collectedIds: collected,
|
|
outForDeliveryIds: released,
|
|
deliveredIds: delivered,
|
|
);
|
|
|
|
group('1 · the six stages, walked once in order', () {
|
|
test('Pending — the offer', () {
|
|
expect(stage('miler_assigned'), RiderStage.pending);
|
|
expect(stage('pending'), RiderStage.pending);
|
|
});
|
|
|
|
test('Accepted — pickup_scheduled, and nothing else', () {
|
|
// The backend's own word for "the rider took it".
|
|
expect(stage('pickup_scheduled'), RiderStage.accepted);
|
|
});
|
|
|
|
test(
|
|
'Arrived — the arrival record, because no server field carries it',
|
|
() {
|
|
// `/reached` writes a timestamp; `GET /miler/bookings` does not return
|
|
// it. Until it does, the rider's own record is the only evidence there
|
|
// is — see `getArrivedOrderIds`.
|
|
expect(stage('pickup_scheduled', arrived: {id}), RiderStage.arrived);
|
|
},
|
|
);
|
|
|
|
test('Picked — either backend word, or the collected record', () {
|
|
expect(stage('converted_to_consignment'), RiderStage.picked);
|
|
expect(stage('picked_up'), RiderStage.picked);
|
|
expect(stage('pickup_scheduled', collected: {id}), RiderStage.picked);
|
|
});
|
|
|
|
test('Active — the delivery is under way', () {
|
|
expect(stage('out_for_delivery'), RiderStage.active);
|
|
expect(stage('active'), RiderStage.active);
|
|
expect(
|
|
stage('converted_to_consignment', released: {id}),
|
|
RiderStage.active,
|
|
reason: 'the rider pressed Start delivery; the round has begun',
|
|
);
|
|
});
|
|
|
|
test('Delivered — terminal', () {
|
|
expect(stage('delivered'), RiderStage.delivered);
|
|
});
|
|
|
|
test('Cancelled sits outside the run', () {
|
|
expect(stage('cancelled'), RiderStage.cancelled);
|
|
expect(stage('rejected'), RiderStage.cancelled);
|
|
});
|
|
|
|
test('the stages are ordered, and each is its own', () {
|
|
final walked = [
|
|
stage('miler_assigned'),
|
|
stage('pickup_scheduled'),
|
|
stage('pickup_scheduled', arrived: {id}),
|
|
stage('converted_to_consignment'),
|
|
stage('out_for_delivery'),
|
|
stage('delivered'),
|
|
];
|
|
expect(walked.toSet(), hasLength(6), reason: 'six distinct stages');
|
|
for (var i = 1; i < walked.length; i++) {
|
|
expect(
|
|
walked[i].rank,
|
|
greaterThan(walked[i - 1].rank),
|
|
reason: '${walked[i]} must rank above ${walked[i - 1]}',
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('2 · progress never runs backwards', () {
|
|
test('a stale arrival cannot un-pick a collected stop', () {
|
|
expect(
|
|
stage('converted_to_consignment', arrived: {id}),
|
|
RiderStage.picked,
|
|
);
|
|
});
|
|
|
|
test('a stale arrival cannot un-start a round', () {
|
|
expect(stage('out_for_delivery', arrived: {id}), RiderStage.active);
|
|
});
|
|
|
|
test('a stale collection cannot un-deliver an order', () {
|
|
expect(
|
|
stage('delivered', arrived: {id}, collected: {id}),
|
|
RiderStage.delivered,
|
|
);
|
|
});
|
|
|
|
test('no local record can revive a cancelled stop', () {
|
|
expect(
|
|
stage('cancelled', arrived: {id}, collected: {id}, released: {id}),
|
|
RiderStage.cancelled,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('3 · the two resolvers answer different questions', () {
|
|
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
|
|
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
|
|
StopStatus pickupRung(String status, {Set<String> collected = const {}}) =>
|
|
MilkRun.stageOf(
|
|
row(status),
|
|
acceptedIds: const {},
|
|
collectedIds: collected,
|
|
);
|
|
|
|
test('delivery under way: Active overall, Picked on the pickup card', () {
|
|
// The case the separation exists for, stated in one test.
|
|
expect(stage('active', collected: {id}), RiderStage.active);
|
|
expect(pickupRung('active', collected: {id}), StopStatus.picked);
|
|
});
|
|
|
|
test('and the pickup milestone is readable from the stage too', () {
|
|
expect(stage('out_for_delivery', collected: {id}).pickupComplete, isTrue);
|
|
expect(stage('delivered').pickupComplete, isTrue);
|
|
expect(stage('pickup_scheduled', arrived: {id}).pickupComplete, isFalse);
|
|
expect(
|
|
stage('cancelled').pickupComplete,
|
|
isFalse,
|
|
reason: 'a cancelled stop was never picked',
|
|
);
|
|
});
|
|
|
|
test('availability is not a booking status and cannot reach either', () {
|
|
// `updateActiveStatus` posts `setAvailability('On_Pickup')` — a fact about
|
|
// the **rider**. It has no booking status to write, and the regression it
|
|
// caused was the app inventing one. Neither resolver takes availability,
|
|
// and there is no spelling of it that parses as a rung.
|
|
expect(stopStatusFromRaw('on_pickup'), StopStatus.unknown);
|
|
expect(stopStatusFromRaw('On_Pickup'), StopStatus.unknown);
|
|
});
|
|
});
|
|
|
|
group('3b · the authoritative contract, field by field', () {
|
|
Map<String, dynamic> booking(
|
|
String status, {
|
|
Object? reachedAt,
|
|
String? consignment,
|
|
}) => {
|
|
'orderid': id,
|
|
'pickupid': id,
|
|
'orderstatus': status,
|
|
if (reachedAt != null) 'reachedat': reachedAt,
|
|
if (consignment != null) 'consignmentstatus': consignment,
|
|
};
|
|
|
|
test('Pickup_Scheduled + no reachedat → Accepted', () {
|
|
expect(riderStageOf(booking('Pickup_Scheduled')), RiderStage.accepted);
|
|
expect(
|
|
riderStageOf(booking('Pickup_Scheduled', reachedAt: '')),
|
|
RiderStage.accepted,
|
|
reason: 'an empty stamp is not an arrival',
|
|
);
|
|
expect(
|
|
riderStageOf(booking('Pickup_Scheduled', reachedAt: 'null')),
|
|
RiderStage.accepted,
|
|
reason: 'a stringified JSON null is not an arrival either',
|
|
);
|
|
});
|
|
|
|
test('Pickup_Scheduled + reachedat → Arrived, from the server alone', () {
|
|
// No local record passed in: this is the rung rebuilt from server data,
|
|
// which is what retires the compatibility store.
|
|
expect(
|
|
riderStageOf(
|
|
booking('Pickup_Scheduled', reachedAt: '2026-08-25T12:33:02+05:30'),
|
|
),
|
|
RiderStage.arrived,
|
|
);
|
|
});
|
|
|
|
test('a malformed stamp is still an arrival, and does not throw', () {
|
|
// Read for presence, not for value: the backend does not write the field
|
|
// for a booking nobody arrived at, so an unparseable one is a bad clock
|
|
// rather than a bad claim. Parsing it and discarding it would lose the
|
|
// rung over a formatting detail.
|
|
for (final junk in const ['not-a-date', 0, '2026-13-45', true]) {
|
|
expect(
|
|
riderStageOf(booking('Pickup_Scheduled', reachedAt: junk)),
|
|
RiderStage.arrived,
|
|
reason: 'reachedat="$junk" threw or was dropped',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('Collected_By_Miler → Picked', () {
|
|
expect(
|
|
riderStageOf(
|
|
booking(
|
|
'Converted_To_Consignment',
|
|
consignment: 'Collected_By_Miler',
|
|
),
|
|
),
|
|
RiderStage.picked,
|
|
);
|
|
});
|
|
|
|
test('Out_for_Delivery → Active', () {
|
|
expect(
|
|
riderStageOf(
|
|
booking('Converted_To_Consignment', consignment: 'Out_for_Delivery'),
|
|
),
|
|
RiderStage.active,
|
|
);
|
|
});
|
|
|
|
test('Delivered → Delivered', () {
|
|
expect(
|
|
riderStageOf(
|
|
booking('Converted_To_Consignment', consignment: 'Delivered'),
|
|
),
|
|
RiderStage.delivered,
|
|
);
|
|
});
|
|
|
|
test('the consignment outranks a stale stamp at every rung', () {
|
|
const stamp = '2026-08-25T12:33:02+05:30';
|
|
for (final (state, expected) in const [
|
|
('Collected_By_Miler', RiderStage.picked),
|
|
('Out_for_Delivery', RiderStage.active),
|
|
('Delivered', RiderStage.delivered),
|
|
]) {
|
|
expect(
|
|
riderStageOf(
|
|
booking('Pickup_Scheduled', reachedAt: stamp, consignment: state),
|
|
arrivedIds: {id},
|
|
collectedIds: {id},
|
|
),
|
|
expected,
|
|
reason: '$state was dragged back by older evidence',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('CROSS-CITY · the hub\'s rungs are not this rider being Active', () {
|
|
// Confirmed by the backend: a cross-city parcel goes
|
|
// Collected_By_Miler → Inwarded_at_Hub → Tripsheet_Loaded → In_Transit
|
|
// and is delivered by somebody else. The rider's involvement ended at
|
|
// the collection, so Picked is where he stays — and no delivery control
|
|
// may be offered for a stop he cannot act on.
|
|
for (final hub in const [
|
|
'Inwarded_at_Hub',
|
|
'Tripsheet_Loaded',
|
|
'In_Transit',
|
|
'Created',
|
|
]) {
|
|
final at = riderStageOf(
|
|
booking('Converted_To_Consignment', consignment: hub),
|
|
);
|
|
expect(
|
|
at,
|
|
RiderStage.picked,
|
|
reason: '"$hub" resolved to $at — the hub half is not his round',
|
|
);
|
|
expect(at, isNot(RiderStage.active));
|
|
}
|
|
});
|
|
});
|
|
|
|
group('4 · restored from persisted state after a restart', () {
|
|
// Reconstruction reads the same evidence the live flow writes, so a
|
|
// relaunch lands a stop exactly where the rider left it — the one thing a
|
|
// derived lifecycle has to get right, because nothing is stored.
|
|
test('pickup_scheduled with no local record → Accepted', () {
|
|
expect(stage('pickup_scheduled'), RiderStage.accepted);
|
|
});
|
|
|
|
test('pickup_scheduled with a surviving arrival record → Arrived', () {
|
|
expect(stage('pickup_scheduled', arrived: {id}), RiderStage.arrived);
|
|
});
|
|
|
|
test('converted_to_consignment → Picked, with no local record at all', () {
|
|
expect(stage('converted_to_consignment'), RiderStage.picked);
|
|
});
|
|
|
|
test('a released round comes back Active', () {
|
|
expect(
|
|
stage('converted_to_consignment', released: {id}),
|
|
RiderStage.active,
|
|
);
|
|
});
|
|
|
|
test('a finished stop comes back Delivered', () {
|
|
expect(stage('delivered'), RiderStage.delivered);
|
|
});
|
|
|
|
test('nothing restores to an earlier rung than it reached', () {
|
|
// Every server word, with a full set of stale local records attached.
|
|
// None may pull the stop back.
|
|
final stale = {id};
|
|
for (final (said, expected) in const [
|
|
('converted_to_consignment', RiderStage.picked),
|
|
('picked_up', RiderStage.picked),
|
|
('out_for_delivery', RiderStage.active),
|
|
('active', RiderStage.active),
|
|
('delivered', RiderStage.delivered),
|
|
]) {
|
|
expect(
|
|
riderStageOf(row(said), arrivedIds: stale),
|
|
expected,
|
|
reason: '"$said" was dragged back by a stale arrival record',
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|