Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
320 lines
12 KiB
Dart
320 lines
12 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:miler/data/api_config.dart';
|
|
import 'package:miler/data/service_profile.dart';
|
|
import 'package:miler/data/task_profile.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// ONE MILER, MIXED WORK — asked of the job, not of the rider
|
|
///
|
|
/// The scenario this exists for, stated as the operation states it:
|
|
///
|
|
/// A rider in RS Puram is assigned **10 kitchen parcels and 5 CX customer
|
|
/// pickups**. All fifteen are his, in one shift, on one route.
|
|
///
|
|
/// Before this, every operational question was answered by
|
|
/// `ServiceProfile.active` — a static resolved once from the rider's tenant,
|
|
/// giving the *same answer for all fifteen rows*. So on a meal tenant the five
|
|
/// CX pickups were walked straight past the door flow (no parcel count, no
|
|
/// destination, no weight, no payment) and pivoted on whatever the customer had
|
|
/// typed into the app days earlier. On a parcel tenant the reverse: ten kitchen
|
|
/// bags with a known manifest asked the rider to itemise each one.
|
|
///
|
|
/// [WorkPolicy] is the bridge. It asks the **row**, and falls back to the line
|
|
/// only where the row is silent — because most rows still are (see BE-6), and a
|
|
/// screen that took `unknown` at face value would lose the milk round its
|
|
/// kitchen headings, which is what happened the two previous times a call site
|
|
/// was migrated wholesale.
|
|
///
|
|
/// These tests pin both halves: the row wins when it speaks, and nothing
|
|
/// changes when it does not.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
setUp(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
|
|
/// A CX customer pickup, as the queue returns it.
|
|
Map<String, dynamic> cxPickup() => ApiConfig.pickupFromBooking(const {
|
|
'bookingid': 101,
|
|
'orderid': 'CX-1',
|
|
'pickup_source_type': 'customer',
|
|
'pickup_source_name': 'Anitha R',
|
|
});
|
|
|
|
/// A kitchen / counter collection.
|
|
Map<String, dynamic> sourcePickup() => ApiConfig.pickupFromBooking(const {
|
|
'bookingid': 201,
|
|
'orderid': 'KIT-1',
|
|
'pickup_source_type': 'merchant',
|
|
'pickup_source_name': 'Sri Balaji Kitchen',
|
|
});
|
|
|
|
/// A row from before these fields existed — the common case today.
|
|
Map<String, dynamic> silentRow() => ApiConfig.pickupFromBooking(const {
|
|
'bookingid': 301,
|
|
'orderid': 'OLD-1',
|
|
});
|
|
|
|
group('the row decides, when the row has said something', () {
|
|
test('a CX customer pickup captures shipment details', () {
|
|
// The five pickups in the scenario. True on BOTH tenants, which is the
|
|
// whole change: it is a fact about the job, not about the rider.
|
|
for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) {
|
|
ServiceProfile.setActive(line);
|
|
expect(
|
|
WorkPolicy.capturesShipmentDetails(cxPickup()),
|
|
isTrue,
|
|
reason: 'a customer door needs the verify flow on ${line.label}',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('a kitchen collection does not', () {
|
|
// The ten parcels. Also true on both tenants — the manifest is known
|
|
// before the rider arrives, whoever he works for.
|
|
for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) {
|
|
ServiceProfile.setActive(line);
|
|
expect(
|
|
WorkPolicy.capturesShipmentDetails(sourcePickup()),
|
|
isFalse,
|
|
reason: 'a counter manifest is not itemised at the door',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the two coexist in one queue, answering differently', () {
|
|
// The regression in one line: fifteen rows, two workflows, one rider.
|
|
final queue = [cxPickup(), sourcePickup()];
|
|
expect(
|
|
queue.map(WorkPolicy.capturesShipmentDetails).toList(),
|
|
[true, false],
|
|
reason:
|
|
'this list was [true, true] or [false, false] before — whichever '
|
|
'the tenant happened to say',
|
|
);
|
|
});
|
|
|
|
test('a CX pickup never becomes a source/kitchen task', () {
|
|
final profile = TaskProfile.of(cxPickup());
|
|
expect(profile.kind, WorkKind.customerPickup);
|
|
expect(
|
|
profile.groupsBySource,
|
|
isFalse,
|
|
reason: 'a customer door is not a counter and must not be grouped '
|
|
'under one like a kitchen manifest',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('the line still decides where the row is silent', () {
|
|
// The guard that makes this safe to ship: every existing row and every
|
|
// existing fixture takes this path, and must behave exactly as before.
|
|
test('a silent row follows the parcel line', () {
|
|
ServiceProfile.setActive(ServiceProfile.parcel);
|
|
expect(
|
|
WorkPolicy.capturesShipmentDetails(silentRow()),
|
|
ServiceProfile.parcel.needsVerification,
|
|
);
|
|
expect(
|
|
WorkPolicy.capturesShipmentAddresses(silentRow()),
|
|
ServiceProfile.parcel.capturesShipmentAddresses,
|
|
);
|
|
expect(
|
|
WorkPolicy.initiatesShipment(silentRow()),
|
|
ServiceProfile.parcel.initiatesShipment,
|
|
);
|
|
});
|
|
|
|
test('a silent row follows the meal line', () {
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
expect(
|
|
WorkPolicy.capturesShipmentDetails(silentRow()),
|
|
ServiceProfile.milkMan.needsVerification,
|
|
);
|
|
expect(
|
|
WorkPolicy.deliversToCustomer(silentRow()),
|
|
ServiceProfile.milkMan.deliversToCustomer,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('when an accepted stop leaves Home', () {
|
|
test('a CX pickup leaves on accept; a kitchen load does not', () {
|
|
// The per-item replacement for `handsOffAtCollection`. Accepting a
|
|
// customer visit is an appointment the rider now owes somebody, so it
|
|
// belongs on Bookings. Accepting a counter load puts nothing in his
|
|
// hands — he still has to go and collect it.
|
|
expect(WorkPolicy.staysOnHomeUntilCollected(cxPickup()), isFalse);
|
|
expect(WorkPolicy.staysOnHomeUntilCollected(sourcePickup()), isTrue);
|
|
});
|
|
|
|
test('and the answer does not change with the tenant', () {
|
|
// The whole failure this replaces: one answer for every row on the
|
|
// screen. On a meal tenant an accepted CX pickup sat on Home beside ten
|
|
// kitchen bags with nothing to tell them apart.
|
|
for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) {
|
|
ServiceProfile.setActive(line);
|
|
expect(
|
|
WorkPolicy.staysOnHomeUntilCollected(cxPickup()),
|
|
isFalse,
|
|
reason: line.label,
|
|
);
|
|
expect(
|
|
WorkPolicy.staysOnHomeUntilCollected(sourcePickup()),
|
|
isTrue,
|
|
reason: line.label,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('a silent row still follows the line', () {
|
|
for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) {
|
|
ServiceProfile.setActive(line);
|
|
expect(
|
|
WorkPolicy.staysOnHomeUntilCollected(silentRow()),
|
|
line.handsOffAtCollection,
|
|
reason: 'existing payloads must behave exactly as before',
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('which surface an item belongs on', () {
|
|
test('an accepted CX pickup moves Home → Bookings', () {
|
|
// The rule the scenario names: accepting a customer visit makes it an
|
|
// appointment the rider owes somebody.
|
|
final row = cxPickup();
|
|
expect(WorkPolicy.surfaceOf(row, accepted: false), WorkSurface.home);
|
|
expect(WorkPolicy.surfaceOf(row, accepted: true), WorkSurface.bookings);
|
|
});
|
|
|
|
test('an accepted kitchen load stays on Home', () {
|
|
// Accepting a counter load puts nothing in his hands — he still has to go
|
|
// and collect it.
|
|
final row = sourcePickup();
|
|
expect(WorkPolicy.surfaceOf(row, accepted: false), WorkSurface.home);
|
|
expect(
|
|
WorkPolicy.surfaceOf(row, accepted: true),
|
|
WorkSurface.home,
|
|
reason: 'nothing is in his hands yet',
|
|
);
|
|
});
|
|
|
|
test('in-custody work is Deliveries, whichever kind it is', () {
|
|
for (final action in const ['deliver', 'start_delivery', 'inward_at_hub']) {
|
|
final row = ApiConfig.pickupFromBooking({
|
|
'bookingid': 401,
|
|
'next_action': action,
|
|
});
|
|
expect(
|
|
WorkPolicy.surfaceOf(row, accepted: true),
|
|
WorkSurface.deliveries,
|
|
reason: action,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('finished work is Activity', () {
|
|
final row = ApiConfig.pickupFromBooking(const {
|
|
'bookingid': 501,
|
|
'next_action': 'handed_to_hub',
|
|
});
|
|
expect(WorkPolicy.surfaceOf(row, accepted: true), WorkSurface.activity);
|
|
});
|
|
});
|
|
|
|
group('a base leg is read, never guessed', () {
|
|
test('only an explicit inward_at_hub ends at a base', () {
|
|
expect(
|
|
WorkPolicy.endsAtHub(
|
|
ApiConfig.pickupFromBooking(const {
|
|
'bookingid': 601,
|
|
'next_action': 'inward_at_hub',
|
|
}),
|
|
),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('a silent row does NOT fall back to the line here', () {
|
|
// Deliberately the one method with no fallback. Guessing a base leg from
|
|
// the rider's tenant is how a hyperlocal parcel gets routed to a counter
|
|
// and sits there.
|
|
ServiceProfile.setActive(ServiceProfile.parcel);
|
|
expect(ServiceProfile.parcel.endsAtHub, isTrue);
|
|
expect(
|
|
WorkPolicy.endsAtHub(silentRow()),
|
|
isFalse,
|
|
reason: 'the line says the day ends at a base; this ROW does not',
|
|
);
|
|
});
|
|
|
|
test('a customer delivery does not end at a base', () {
|
|
expect(
|
|
WorkPolicy.endsAtHub(
|
|
ApiConfig.pickupFromBooking(const {
|
|
'bookingid': 701,
|
|
'next_action': 'deliver',
|
|
}),
|
|
),
|
|
isFalse,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('the RS Puram round', () {
|
|
test('15 assigned items keep 15 distinct answers, and none vanish', () {
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
|
|
final round = <Map<String, dynamic>>[
|
|
for (var i = 0; i < 10; i++)
|
|
ApiConfig.pickupFromBooking({
|
|
'bookingid': 1000 + i,
|
|
'orderid': 'KIT-$i',
|
|
'pickup_source_type': 'merchant',
|
|
'pickup_source_name': 'Kitchen 2',
|
|
}),
|
|
for (var i = 0; i < 5; i++)
|
|
ApiConfig.pickupFromBooking({
|
|
'bookingid': 2000 + i,
|
|
'orderid': 'CX-$i',
|
|
'pickup_source_type': 'customer',
|
|
'pickup_source_name': 'Customer $i',
|
|
}),
|
|
];
|
|
|
|
expect(round, hasLength(15), reason: 'nothing may be filtered away');
|
|
|
|
final kitchens = round.where(
|
|
(r) => TaskProfile.of(r).kind == WorkKind.sourcePickup,
|
|
);
|
|
final customers = round.where(
|
|
(r) => TaskProfile.of(r).kind == WorkKind.customerPickup,
|
|
);
|
|
expect(kitchens, hasLength(10));
|
|
expect(customers, hasLength(5));
|
|
|
|
// Accepting all fifteen sends the two kinds to two different surfaces.
|
|
expect(
|
|
kitchens.map((r) => WorkPolicy.surfaceOf(r, accepted: true)).toSet(),
|
|
{WorkSurface.home},
|
|
);
|
|
expect(
|
|
customers.map((r) => WorkPolicy.surfaceOf(r, accepted: true)).toSet(),
|
|
{WorkSurface.bookings},
|
|
);
|
|
|
|
// And only the five customer doors run the verify flow — on a meal
|
|
// tenant, where previously none of them would have.
|
|
expect(
|
|
customers.every(WorkPolicy.capturesShipmentDetails),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
kitchens.any(WorkPolicy.capturesShipmentDetails),
|
|
isFalse,
|
|
);
|
|
});
|
|
});
|
|
}
|