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