291 lines
12 KiB
Dart
291 lines
12 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:miler/data/next_leg.dart';
|
|
import 'package:miler/data/service_profile.dart';
|
|
import 'package:miler/data/work_domain.dart';
|
|
import 'package:miler/data/milk_run.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// WHERE A COLLECTED PARCEL GOES NEXT
|
|
///
|
|
/// The logistics line used to answer this with a line-level constant —
|
|
/// `ServiceProfile.endsAtHub` — and `work_domain.dart` closed every collected
|
|
/// parcel because of it. These pin the per-order replacement against the four
|
|
/// operational cases it exists for:
|
|
///
|
|
/// 1. Hub H1 → customer collected at a hub, delivered by this rider
|
|
/// 2. customer → hub collected at a door, handed to the network
|
|
/// 3. customer → customer hyperlocal, one rider, no hub
|
|
/// 4. customer → another state collected here, handed to the local hub
|
|
///
|
|
/// The routing decision is the **backend's** in every one of them. No test here
|
|
/// asserts anything about a pincode, a district or a city name, because nothing
|
|
/// in the app is allowed to read one for this purpose.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
setUp(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
|
|
/// A queue row as `GET /miler/bookings` delivers it, after the adapter.
|
|
Map<String, dynamic> row(
|
|
String id, {
|
|
String status = 'picked',
|
|
String consignment = '',
|
|
String nextAction = '',
|
|
String source = '',
|
|
String drop = '',
|
|
}) => <String, dynamic>{
|
|
'orderid': id,
|
|
'orderstatus': status,
|
|
'consignmentstatus': consignment,
|
|
if (nextAction.isNotEmpty) 'next_action': nextAction,
|
|
'sourcename': source,
|
|
'dropaddress': drop,
|
|
};
|
|
|
|
group('the four operational cases', () {
|
|
test('CASE 1 — Hub H1 → customer: the rider delivers it himself', () {
|
|
// Collected at the hub; the pivot released it into his hands.
|
|
final stop = row(
|
|
'H1-001',
|
|
consignment: 'Collected_By_Miler',
|
|
source: 'Hub H1',
|
|
drop: 'Hopes, Coimbatore',
|
|
);
|
|
|
|
expect(NextLegResolver.resolve(stop).leg, NextLeg.customer);
|
|
expect(WorkBoundary.isClosed(stop), isFalse,
|
|
reason: 'a parcel in his hands is not finished work');
|
|
expect(WorkBoundary.domainOf(stop), WorkDomain.delivery);
|
|
expect(MilkRun.navigatesToCustomer(stop), isTrue,
|
|
reason: 'the receiver is the immediate destination');
|
|
});
|
|
|
|
test('CASE 3 — customer → customer hyperlocal: never closed at pickup', () {
|
|
// Gandhipuram → Hopes. The pivot released it in the same call, which is
|
|
// what compatibility mode does, so the row comes back Out_for_Delivery.
|
|
final stop = row(
|
|
'HYP-1',
|
|
consignment: 'Out_for_Delivery',
|
|
source: 'Rajesh Kumar',
|
|
drop: 'Hopes, Coimbatore',
|
|
);
|
|
|
|
expect(NextLegResolver.resolve(stop).leg, NextLeg.customer);
|
|
|
|
// ── The bug this whole change exists for ──
|
|
//
|
|
// `isPicked && endsAtHub` closed this row, and endsAtHub is always true
|
|
// on logistics. The rider walked away from Rajesh Kumar's door carrying a
|
|
// parcel the app had filed as finished.
|
|
expect(WorkBoundary.isClosed(stop), isFalse);
|
|
expect(WorkBoundary.domainOf(stop), WorkDomain.delivery);
|
|
expect(MilkRun.navigatesToCustomer(stop), isTrue);
|
|
});
|
|
|
|
test('CASE 4 — customer → another state: hub leg, no customer delivery', () {
|
|
// Gandhipuram → Chennai. The pivot routed it into the network.
|
|
final stop = row(
|
|
'ICT-1',
|
|
consignment: 'Created',
|
|
source: 'Rajesh Kumar',
|
|
drop: 'T Nagar, Chennai',
|
|
);
|
|
const pivot = {'ICT-1': NextActionWord.inwardAtHub};
|
|
|
|
expect(
|
|
NextLegResolver.resolve(stop, pivotAction: NextActionWord.inwardAtHub).leg,
|
|
NextLeg.hub,
|
|
);
|
|
|
|
// It is in his custody, so it is the work tab's — not closed, not Home's.
|
|
expect(
|
|
WorkBoundary.isClosed(stop, pivotActions: pivot),
|
|
isFalse,
|
|
reason: 'a parcel he is carrying must not vanish into Activity',
|
|
);
|
|
expect(
|
|
WorkBoundary.domainOf(stop, pivotActions: pivot),
|
|
WorkDomain.delivery,
|
|
);
|
|
|
|
// ── The safety assertion ──
|
|
//
|
|
// Chennai is 500km away. Nothing may offer it as this rider's next
|
|
// navigation target.
|
|
expect(
|
|
MilkRun.navigatesToCustomer(stop, pivotActions: pivot),
|
|
isFalse,
|
|
reason: 'a hub-routed parcel must never point at its receiver',
|
|
);
|
|
});
|
|
|
|
test('CASE 2 — the hub taking it in is what ends the rider s job', () {
|
|
// There is no rider-side handover mutation yet (backend gap). The one way
|
|
// a hub-routed parcel legitimately leaves his queue today is hub staff
|
|
// inwarding it in their console, which the next poll reports.
|
|
final handed = row('ICT-1', consignment: 'Inwarded_at_Hub');
|
|
const pivot = {'ICT-1': NextActionWord.inwardAtHub};
|
|
|
|
expect(NextLegResolver.resolve(handed, pivotAction: NextActionWord.inwardAtHub).leg,
|
|
NextLeg.closed);
|
|
expect(WorkBoundary.isClosed(handed, pivotActions: pivot), isTrue);
|
|
expect(
|
|
WorkBoundary.domainOf(handed, pivotActions: pivot),
|
|
WorkDomain.closed,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('precedence — the server outranks the local record', () {
|
|
test('a live consignment state beats a stale pivot instruction', () {
|
|
// The pivot said hub. The server has since said the parcel is out for
|
|
// delivery in this rider's hands. The server wins: a local record is
|
|
// continuity, never authority.
|
|
final stop = row('X1', consignment: 'Out_for_Delivery');
|
|
final decision =
|
|
NextLegResolver.resolve(stop, pivotAction: NextActionWord.inwardAtHub);
|
|
|
|
expect(decision.leg, NextLeg.customer);
|
|
expect(decision.source, 'row.consignmentstatus');
|
|
});
|
|
|
|
test('a terminal state beats everything', () {
|
|
final stop = row('X2', status: 'delivered', consignment: 'Delivered');
|
|
expect(
|
|
NextLegResolver.resolve(stop, pivotAction: NextActionWord.startDelivery).leg,
|
|
NextLeg.closed,
|
|
);
|
|
});
|
|
|
|
test('live next_action on the row outranks the persisted pivot', () {
|
|
// Not sent by the backend today — pinned so the resolver is already
|
|
// correct on the day `GET /miler/bookings` carries it.
|
|
final stop = row('X3', consignment: 'Created', nextAction: 'start_delivery');
|
|
final decision =
|
|
NextLegResolver.resolve(stop, pivotAction: NextActionWord.inwardAtHub);
|
|
|
|
expect(decision.leg, NextLeg.customer);
|
|
expect(decision.source, 'row.next_action');
|
|
});
|
|
|
|
test('Created alone decides nothing — it is also the transient state', () {
|
|
// `Created` is what a hub-routed pivot lands on AND what a consignment
|
|
// holds while the pivot is still routing it. Reading it as "hub" would
|
|
// strand every freshly-collected hyperlocal parcel.
|
|
final stop = row('X4', consignment: 'Created');
|
|
final decision = NextLegResolver.resolve(stop);
|
|
|
|
expect(decision.leg, NextLeg.unknown);
|
|
expect(decision.source, 'none');
|
|
});
|
|
|
|
test('unknown keeps custody: not closed, and no customer destination', () {
|
|
final stop = row('X5', consignment: 'Created');
|
|
|
|
// Never written off — he is holding it.
|
|
expect(WorkBoundary.isClosed(stop), isFalse);
|
|
expect(WorkBoundary.domainOf(stop), WorkDomain.delivery);
|
|
// Never pointed anywhere — the server has not said where it goes.
|
|
expect(MilkRun.navigatesToCustomer(stop), isFalse);
|
|
});
|
|
});
|
|
|
|
group('the backend outranks the ServiceProfile default', () {
|
|
test('logistics stays endsAtHub, and it no longer closes an order', () {
|
|
// The capability is untouched — it still describes the line.
|
|
expect(ServiceProfile.parcel.endsAtHub, isTrue);
|
|
expect(ServiceProfile.parcel.deliversToCustomer, isFalse);
|
|
|
|
// And it no longer decides one order's fate.
|
|
final hyperlocal = row('P1', consignment: 'Collected_By_Miler');
|
|
expect(WorkBoundary.isClosed(hyperlocal), isFalse);
|
|
expect(MilkRun.navigatesToCustomer(hyperlocal), isTrue);
|
|
});
|
|
|
|
test('a milk run is unchanged by any of this', () {
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
|
|
// A round's rows often carry no consignment state at all; the line's own
|
|
// shape still answers for them.
|
|
final collected = row('M1', status: 'picked');
|
|
expect(WorkBoundary.isClosed(collected), isFalse);
|
|
expect(
|
|
MilkRun.navigatesToCustomer(collected, collectedIds: const {'M1'}),
|
|
isTrue,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('the leg decides the words and the controls', () {
|
|
test('only a customer leg may be released into a delivery round', () {
|
|
// The gate that keeps a Chennai consignment out of `Out_for_Delivery`.
|
|
expect(NextLeg.customer.allowsCustomerDelivery, isTrue);
|
|
expect(NextLeg.hub.allowsCustomerDelivery, isFalse);
|
|
expect(NextLeg.unknown.allowsCustomerDelivery, isFalse);
|
|
expect(NextLeg.closed.allowsCustomerDelivery, isFalse);
|
|
});
|
|
|
|
test('a handover and a delivery are never worded the same', () {
|
|
// Activity has to be able to tell "handed across a counter" from "given
|
|
// to the person it belongs to". One verb for both is a false record.
|
|
expect(
|
|
NextLeg.hub.completionVerb,
|
|
isNot(equals(NextLeg.customer.completionVerb)),
|
|
);
|
|
expect(NextLeg.customer.completionVerb, contains('customer'));
|
|
});
|
|
|
|
test('no leg vocabulary says "hub" to a rider', () {
|
|
// The house rule, enforced for every rider-facing string by
|
|
// `no_hub_in_rider_copy_test.dart`. Pinned here too because this is where
|
|
// the words are chosen: "base" for the place, "your office" for people.
|
|
for (final leg in NextLeg.values) {
|
|
expect(leg.eyebrow.toLowerCase(), isNot(contains('hub')));
|
|
expect(leg.completionVerb.toLowerCase(), isNot(contains('hub')));
|
|
}
|
|
});
|
|
|
|
test('a closed order has no eyebrow to draw', () {
|
|
expect(NextLeg.closed.eyebrow, isEmpty);
|
|
});
|
|
});
|
|
|
|
group('one owner per order', () {
|
|
test('a picked parcel is never on Home and Deliveries at once', () {
|
|
final day = [
|
|
row('A', consignment: 'Out_for_Delivery'), // hyperlocal, customer leg
|
|
row('B', consignment: 'Created'), // hub-routed
|
|
row('C', status: 'assigned', consignment: ''), // not collected
|
|
];
|
|
const pivot = {'B': NextActionWord.inwardAtHub};
|
|
|
|
final home = WorkBoundary.pickupQueue(day, pivotActions: pivot);
|
|
final deliveries = WorkBoundary.deliveryQueue(day, pivotActions: pivot);
|
|
|
|
final homeIds = home.map(MilkRun.idOf).toSet();
|
|
final deliveryIds = deliveries.map(MilkRun.idOf).toSet();
|
|
|
|
expect(homeIds.intersection(deliveryIds), isEmpty,
|
|
reason: 'dual ownership is the failure this boundary exists to stop');
|
|
expect(deliveryIds, containsAll(<String>{'A', 'B'}));
|
|
expect(homeIds, contains('C'));
|
|
});
|
|
|
|
test('restart and poll preserve the leg', () {
|
|
// Same row, read again on a later poll with nothing but the persisted
|
|
// pivot to go on — which is exactly the cold-start case.
|
|
final stop = row('B', consignment: 'Created');
|
|
const pivot = {'B': NextActionWord.inwardAtHub};
|
|
|
|
for (var i = 0; i < 3; i++) {
|
|
expect(
|
|
WorkBoundary.domainOf(stop, pivotActions: pivot),
|
|
WorkDomain.delivery,
|
|
);
|
|
expect(MilkRun.navigatesToCustomer(stop, pivotActions: pivot), isFalse);
|
|
}
|
|
});
|
|
});
|
|
}
|