Files
doormile_milderapp/test/hub_handover_contract_test.dart
2026-09-09 12:55:23 +05:30

292 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
}