292 lines
12 KiB
Dart
292 lines
12 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
||
|
||
import 'package:miler/Models/stop_status.dart';
|
||
import 'package:miler/data/api_config.dart';
|
||
import 'package:miler/data/consignment_state.dart';
|
||
import 'package:miler/data/lifecycle.dart';
|
||
import 'package:miler/data/milk_run.dart';
|
||
import 'package:miler/data/miler_api.dart';
|
||
import 'package:miler/data/next_leg.dart';
|
||
import 'package:miler/data/service_profile.dart';
|
||
import 'package:miler/data/work_domain.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// THE BASE-HANDOVER CONTRACT, AS THE BACKEND SHIPPED IT
|
||
///
|
||
/// Requests 25–31 landed together, and the app's half of them is: read the four
|
||
/// new fields off the row, understand the six `next_action` words, and be
|
||
/// correct in **both positions of `MILER_HUB_HANDOVER_ENABLED`** — because that
|
||
/// flag is flipped server-side and no app release gets to sit in between.
|
||
///
|
||
/// off pickup-complete → Inwarded_at_Hub + handed_to_hub rider is done
|
||
/// on pickup-complete → Created + inward_at_hub rider carries it
|
||
///
|
||
/// These pin both, plus the field plumbing that makes them readable at all: the
|
||
/// booking adapter builds a **fixed map**, so a field it does not name is a
|
||
/// field no screen can ever see, however faithfully the backend sends it.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
void main() {
|
||
setUp(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||
|
||
group('the adapter carries the new fields', () {
|
||
// The trap `step`, `stoptype` and every timestamp were in before it: the
|
||
// server sends the field, the adapter has no key for it, and the app is
|
||
// blind to something that was on the wire all along.
|
||
final adapted = ApiConfig.pickupFromBooking(const {
|
||
'bookingid': 4821,
|
||
'status': 'Converted_To_Consignment',
|
||
'consignmentstatus': 'Created',
|
||
'next_action': 'inward_at_hub',
|
||
'next_hub': {
|
||
'id': 1,
|
||
'name': 'Coimbatore Hub',
|
||
'address': '14 Avinashi Road, Peelamedu, Coimbatore',
|
||
'pincode': '641004',
|
||
'latitude': 11.0272,
|
||
'longitude': 76.9905,
|
||
},
|
||
'pickup_source_type': 'customer',
|
||
'pickup_source_name': 'Anitha R',
|
||
});
|
||
|
||
test('next_action survives', () {
|
||
expect(adapted['next_action'], 'inward_at_hub');
|
||
expect(NextLegResolver.rowNextAction(adapted), 'inward_at_hub');
|
||
});
|
||
|
||
test('next_hub survives whole, and is navigable', () {
|
||
final base = HandoverHub.from(adapted['next_hub']);
|
||
expect(base, isNotNull);
|
||
expect(base!.id, '1');
|
||
expect(base.name, 'Coimbatore Hub');
|
||
expect(base.pincode, '641004');
|
||
expect(base.latitude, 11.0272);
|
||
expect(base.isNavigable, isTrue,
|
||
reason: 'coordinates are the whole point of request 25');
|
||
});
|
||
|
||
test('pickup_source_type and _source_name survive', () {
|
||
expect(adapted['pickup_source_type'], 'customer');
|
||
expect(PickupSource.of(adapted), PickupSource.customer);
|
||
expect(adapted['pickup_source_name'], 'Anitha R');
|
||
});
|
||
|
||
test('the counter name outranks the account name', () {
|
||
// `sourcename` is filled from providercompany/providerlocation and is a
|
||
// contact person on some rows — this is the fix for `Sudharsan` heading a
|
||
// route card.
|
||
final row = ApiConfig.pickupFromBooking(const {
|
||
'bookingid': 7,
|
||
'sourcename': 'Sudharsan',
|
||
'pickup_source_name': 'Sri Balaji Stores',
|
||
});
|
||
expect(MilkRun.sourceNameOf(row), 'Sri Balaji Stores');
|
||
});
|
||
});
|
||
|
||
group('the full next_action vocabulary', () {
|
||
Map<String, dynamic> row(String action, {String consignment = ''}) => {
|
||
'orderid': 'A1',
|
||
'orderstatus': 'picked',
|
||
'consignmentstatus': consignment,
|
||
'next_action': action,
|
||
};
|
||
|
||
test('deliver and start_delivery are both customer legs', () {
|
||
expect(NextLegResolver.resolve(row('start_delivery')).leg, NextLeg.customer);
|
||
expect(NextLegResolver.resolve(row('deliver')).leg, NextLeg.customer);
|
||
});
|
||
|
||
test('inward_at_hub is a base leg, handed_to_hub is finished', () {
|
||
expect(NextLegResolver.resolve(row('inward_at_hub')).leg, NextLeg.hub);
|
||
expect(NextLegResolver.resolve(row('handed_to_hub')).leg, NextLeg.closed);
|
||
});
|
||
|
||
test('pickup and none decide nothing on their own', () {
|
||
// `pickup` is "not collected yet", which is not a leg. `none` means
|
||
// terminal — and rung 1 reads that off the consignment's own state, which
|
||
// is stronger evidence than a bare word.
|
||
expect(NextLegResolver.resolve(row('pickup')).leg, NextLeg.unknown);
|
||
expect(NextLegResolver.resolve(row('none')).leg, NextLeg.unknown);
|
||
});
|
||
|
||
test('an unknown word falls through instead of failing', () {
|
||
final decision = NextLegResolver.resolve(
|
||
row('teleport_to_mars', consignment: 'Out_for_Delivery'),
|
||
);
|
||
expect(decision.leg, NextLeg.customer,
|
||
reason: 'the consignment state still answers');
|
||
});
|
||
});
|
||
|
||
group('correct in both positions of the flag', () {
|
||
// The row as `GET /miler/bookings` returns it after the pivot, adapted.
|
||
Map<String, dynamic> afterPickup({
|
||
required String consignment,
|
||
required String action,
|
||
}) => ApiConfig.pickupFromBooking({
|
||
'bookingid': 4821,
|
||
'status': 'Converted_To_Consignment',
|
||
'consignmentstatus': consignment,
|
||
'next_action': action,
|
||
});
|
||
|
||
test('FLAG OFF — the rider is done at the pickup', () {
|
||
// pickup-complete inwards it immediately; the assignment closes there.
|
||
final row = afterPickup(
|
||
consignment: 'Inwarded_at_Hub',
|
||
action: 'handed_to_hub',
|
||
);
|
||
|
||
expect(stopStatusOf(row).isPicked, isTrue,
|
||
reason: 'the booking reached Converted_To_Consignment');
|
||
expect(NextLegResolver.resolve(row).leg, NextLeg.closed);
|
||
expect(WorkBoundary.domainOf(row), WorkDomain.closed);
|
||
});
|
||
|
||
test('FLAG ON — the rider is carrying it to a base', () {
|
||
final row = afterPickup(consignment: 'Created', action: 'inward_at_hub');
|
||
|
||
final decision = NextLegResolver.resolve(row);
|
||
expect(decision.leg, NextLeg.hub);
|
||
// ── Read from the row, not from the handset ──
|
||
//
|
||
// The pivot cache was only ever a workaround for `next_action` not being
|
||
// on the queue read. Request 27 put it there, so the live field answers
|
||
// and the cache is not consulted at all.
|
||
expect(decision.source, 'row.next_action');
|
||
|
||
expect(WorkBoundary.isClosed(row), isFalse);
|
||
expect(WorkBoundary.domainOf(row), WorkDomain.delivery);
|
||
expect(MilkRun.navigatesToCustomer(row), isFalse,
|
||
reason: 'a base-routed parcel never points at its receiver');
|
||
});
|
||
|
||
test('and neither position needs the local cache', () {
|
||
// Passing no pivotAction at all — a fresh install mid-day.
|
||
for (final pair in const [
|
||
('Inwarded_at_Hub', 'handed_to_hub', NextLeg.closed),
|
||
('Created', 'inward_at_hub', NextLeg.hub),
|
||
('Collected_By_Miler', 'start_delivery', NextLeg.customer),
|
||
('Out_for_Delivery', 'deliver', NextLeg.customer),
|
||
]) {
|
||
final row = afterPickup(consignment: pair.$1, action: pair.$2);
|
||
expect(NextLegResolver.resolve(row).leg, pair.$3,
|
||
reason: '${pair.$1} / ${pair.$2}');
|
||
}
|
||
});
|
||
});
|
||
|
||
group('the handover response is read for what it proves', () {
|
||
ApiResult ok(Map<String, dynamic> data) =>
|
||
ApiResult(ok: true, status: 200, data: data);
|
||
|
||
test('a fresh handover is confirmed by the state it names', () {
|
||
final t = MilerLifecycle.inwardAtHub(ok(const {
|
||
'consignmentstatus': 'Inwarded_at_Hub',
|
||
'inwardedat': '2026-09-02T14:22:10Z',
|
||
'next_action': 'handed_to_hub',
|
||
'already_inwarded': false,
|
||
}));
|
||
expect(t.isConfirmed, isTrue);
|
||
expect(t.consignmentState, ConsignmentState.inwardedAtHub);
|
||
});
|
||
|
||
test('already_inwarded is a success, never a failure', () {
|
||
// He handed it over, the reply was lost, he pressed again. The parcel is
|
||
// on the base's counter and he cannot prove that from where he stands —
|
||
// so the second answer confirms the first.
|
||
final t = MilerLifecycle.inwardAtHub(ok(const {
|
||
'consignmentstatus': 'Inwarded_at_Hub',
|
||
'already_inwarded': true,
|
||
}));
|
||
expect(t.isConfirmed, isTrue);
|
||
});
|
||
|
||
test('a refusal carries its code', () {
|
||
final t = MilerLifecycle.inwardAtHub(const ApiResult(
|
||
ok: false,
|
||
status: 400,
|
||
code: 'INVALID_STATE',
|
||
message: 'already out for delivery',
|
||
));
|
||
expect(t.isRefused, isTrue);
|
||
expect(t.code, 'INVALID_STATE');
|
||
});
|
||
|
||
test('a 200 that names no state is not proof', () {
|
||
// The rule request 15 exists for: a bare success is not a transition.
|
||
final t = MilerLifecycle.inwardAtHub(ok(const {'success': true}));
|
||
expect(t.isUnconfirmed, isTrue);
|
||
});
|
||
});
|
||
|
||
group('pickup source types', () {
|
||
test('each type reads off the row', () {
|
||
for (final pair in const [
|
||
('hub', PickupSource.hub),
|
||
('customer', PickupSource.customer),
|
||
('merchant', PickupSource.merchant),
|
||
('store', PickupSource.store),
|
||
]) {
|
||
expect(PickupSource.of({'pickup_source_type': pair.$1}), pair.$2);
|
||
}
|
||
});
|
||
|
||
test('an unknown or missing type is a plain pickup, never a guess', () {
|
||
expect(PickupSource.of(const {}), PickupSource.unknown);
|
||
expect(
|
||
PickupSource.of(const {'pickup_source_type': 'spaceport'}),
|
||
PickupSource.unknown,
|
||
);
|
||
expect(PickupSource.unknown.eyebrow, 'PICKUP');
|
||
});
|
||
|
||
test('no source eyebrow says "hub" to a rider', () {
|
||
// The house rule — see `no_hub_in_rider_copy_test.dart`. Base, not hub.
|
||
for (final s in PickupSource.values) {
|
||
expect(s.eyebrow.toLowerCase(), isNot(contains('hub')));
|
||
}
|
||
expect(PickupSource.hub.eyebrow, 'BASE PICKUP');
|
||
});
|
||
|
||
test('counters are collected in bulk; a door is not', () {
|
||
expect(PickupSource.hub.isCounter, isTrue);
|
||
expect(PickupSource.merchant.isCounter, isTrue);
|
||
expect(PickupSource.store.isCounter, isTrue);
|
||
expect(PickupSource.customer.isCounter, isFalse);
|
||
});
|
||
});
|
||
|
||
group('At_Customer is the pickup arrival', () {
|
||
test('it no longer reads as arrival at the receiver', () {
|
||
// Settled by the backend: it is a milerprofiles.availabilitystatus value
|
||
// written only by `/reached`, so it says where the RIDER is. Reading it
|
||
// as `deliveryArrived` moved a stop into the delivery half of the day.
|
||
for (final raw in const ['At_Customer', 'at_customer', 'at customer']) {
|
||
expect(stopStatusFromRaw(raw), StopStatus.arrived, reason: raw);
|
||
}
|
||
});
|
||
|
||
test('the real delivery-arrival spellings are untouched', () {
|
||
expect(stopStatusFromRaw('delivery_arrived'), StopStatus.deliveryArrived);
|
||
expect(stopStatusFromRaw('deliveryarrived'), StopStatus.deliveryArrived);
|
||
});
|
||
});
|
||
|
||
group('a base without coordinates is a caption, not a destination', () {
|
||
test('isNavigable is false and the card must not open a map', () {
|
||
final base = HandoverHub.from(const {'id': 2, 'name': 'Salem Hub'});
|
||
expect(base, isNotNull);
|
||
expect(base!.isNavigable, isFalse,
|
||
reason: 'navigating to 0,0 lands in the Gulf of Guinea');
|
||
});
|
||
|
||
test('an empty object is not a base at all', () {
|
||
expect(HandoverHub.from(const {}), isNull);
|
||
expect(HandoverHub.from(null), isNull);
|
||
});
|
||
});
|
||
}
|