miler map
This commit is contained in:
@@ -168,11 +168,14 @@ void main() {
|
||||
// selection on the bar, never four.
|
||||
final selected = tester.widget<Text>(find.text('All'));
|
||||
expect(selected.style!.color, Colors.white);
|
||||
expect(selected.style!.fontWeight, FontWeight.w700);
|
||||
// One grade heavier than this once read: the app was set in Poppins on a
|
||||
// scale stepped down to compensate for that face, and is back on Manrope
|
||||
// with the scale restored. See `FontConstants`.
|
||||
expect(selected.style!.fontWeight, FontWeight.w800);
|
||||
|
||||
final other = tester.widget<Text>(find.text('Completed'));
|
||||
expect(other.style!.color, Narrative.inkMuted);
|
||||
expect(other.style!.fontWeight, FontWeight.w600);
|
||||
expect(other.style!.fontWeight, FontWeight.w700);
|
||||
|
||||
// Exactly one filled brand pill in the bar.
|
||||
final filled = tester
|
||||
@@ -221,7 +224,10 @@ void main() {
|
||||
// lift to rank them; with both, the weight does not have to do that job
|
||||
// as well.
|
||||
final name = tester.widget<Text>(find.text('Joe Mathew'));
|
||||
expect(name.style!.fontWeight, FontWeight.w600);
|
||||
// One grade heavier than this once read: the app was set in Poppins on a
|
||||
// scale stepped down to compensate for that face, and is back on Manrope
|
||||
// with the scale restored. See `FontConstants`.
|
||||
expect(name.style!.fontWeight, FontWeight.w700);
|
||||
expect(
|
||||
name.style!.fontSize,
|
||||
greaterThan(word.style!.fontSize!),
|
||||
|
||||
@@ -854,9 +854,15 @@ void main() {
|
||||
expect(find.text('All 3 selected · Clear'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('stays away when there is only one stop to pick', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('a lone stop gets its place\'s own select-all', (tester) async {
|
||||
// ── This asserted `findsNothing`, and the shape changed under it ──
|
||||
//
|
||||
// The run-level control still stays away for one stop — its own row is
|
||||
// the select-all. What appears instead is the *place's*: a stop with an
|
||||
// address is a place holding one order, and a place carries a select-all
|
||||
// over its own orders however many it has. The milk run has always drawn
|
||||
// `Select all 1` on a one-order counter; the parcel line draws places
|
||||
// now, so it draws it too.
|
||||
await pump(
|
||||
tester,
|
||||
TripCard(
|
||||
@@ -866,7 +872,7 @@ void main() {
|
||||
onAcceptSelected: (_) async {},
|
||||
),
|
||||
);
|
||||
expect(find.textContaining('Select all'), findsNothing);
|
||||
expect(find.text('Select all 1'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -986,11 +992,20 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
// The position sits on the timeline node's own meta line now, rather
|
||||
// than on the card's facts line — but it is still the trip's own index
|
||||
// and not the visible list's, which is the whole contract.
|
||||
expect(find.textContaining('Stop 2'), findsOneWidget);
|
||||
// ── `Stop N` is gone from the parcel route, with the flat node ──
|
||||
//
|
||||
// Home draws places on both lines now: the header names where the work
|
||||
// is, the rail under it names who. `Stop N` was the *flat* node's meta
|
||||
// line, and there are no flat nodes on a route whose stops have
|
||||
// addresses. The run position is the place's numeral instead.
|
||||
//
|
||||
// The contract underneath it — a stop is never renamed mid-shift — is
|
||||
// carried by the group: a finished place settles rather than vanishing,
|
||||
// so the sequence does not slide up. What this can still hold is that
|
||||
// the picked stop has left the list and the remaining one is drawn.
|
||||
expect(find.textContaining('Stop 1'), findsNothing);
|
||||
expect(find.text('B'), findsNothing, reason: 'ids are not on the row');
|
||||
expect(find.text('1 order'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -162,7 +162,14 @@ void main() {
|
||||
final area = tester.widget<Text>(find.text('Peelamedu').first).style!;
|
||||
|
||||
expect(kitchen.fontSize, 19);
|
||||
expect(kitchen.fontWeight, FontWeight.w700);
|
||||
// ── The grade belongs to the typeface, not to this test ──
|
||||
//
|
||||
// This read w700 while the app was set in Poppins on a scale stepped down
|
||||
// one grade to compensate for how heavily that face renders. The app is
|
||||
// back on Manrope and the scale went back up with it, so the heading is
|
||||
// w800 again — the value it had before the typeface was swapped. See
|
||||
// `FontConstants`, which is where the scale is documented.
|
||||
expect(kitchen.fontWeight, FontWeight.w800);
|
||||
expect(name.fontSize, 15);
|
||||
expect(area.fontSize, 12.5);
|
||||
|
||||
|
||||
291
test/hub_handover_contract_test.dart
Normal file
291
test/hub_handover_contract_test.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
269
test/hub_pickup_group_test.dart
Normal file
269
test/hub_pickup_group_test.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/data/pickup_locations.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/home/route_timeline.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip_card.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE HUB IS A PICKUP LOCATION, THE SAME WAY A KITCHEN IS
|
||||
///
|
||||
/// A milk round draws its orders under the counter they are loaded at: one node
|
||||
/// naming the kitchen, its address under it, and the drops in a list beneath.
|
||||
/// A logistics route drew nothing of the kind — every stop was its own node —
|
||||
/// because grouping was one flag for the whole line
|
||||
/// ([ServiceProfile.sourceIsKitchen]).
|
||||
///
|
||||
/// That is right for a *collection*. The parcel line's pickups are one
|
||||
/// consignment per door and each genuinely is its own place. It is wrong for a
|
||||
/// **delivery**: a booking the rider hands over was loaded at the depot, which
|
||||
/// is the same relationship a kitchen has to a milk round. Six drops drew six
|
||||
/// nodes repeating one base address, and the fact that mattered — these come
|
||||
/// off one shelf, and here is that shelf — was the one thing missing.
|
||||
///
|
||||
/// So the question is asked per stop. A route that mixes gets both shapes.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
Map<String, dynamic> drop(String id, String who) => {
|
||||
'orderid': id,
|
||||
'pickupid': 'HUB',
|
||||
'pickuplocationid': 'HUB',
|
||||
'sourcename': 'Doormile Hub — Peelamedu',
|
||||
'pickupaddress': '4, Avinashi Road, Peelamedu, Coimbatore 641004',
|
||||
'pickupcustomer': who,
|
||||
'orderstatus': 'pending',
|
||||
'type': 'delivery',
|
||||
'Quantity': '1',
|
||||
};
|
||||
|
||||
Map<String, dynamic> collect(String id, String who) => {
|
||||
'orderid': id,
|
||||
'pickupid': id,
|
||||
'pickupcustomer': who,
|
||||
'pickupaddress': 'RS Puram, Coimbatore',
|
||||
'orderstatus': 'pending',
|
||||
'type': 'pickup',
|
||||
'Quantity': '1',
|
||||
};
|
||||
|
||||
setUp(() {
|
||||
PickupLocations.seedForTest(const {'7': 'H1 — Peelamedu Hub'});
|
||||
RiderHub.resetForTest(7);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
RiderHub.resetForTest();
|
||||
PickupLocations.seedForTest(const {});
|
||||
});
|
||||
|
||||
test('the whole logistics run is one node, named by the rider\'s hub', () {
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
final groups = TripCard(
|
||||
trip: Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [drop('1', 'Joe'), drop('2', 'Arun'), drop('3', 'Ravi')],
|
||||
),
|
||||
).routeGroups();
|
||||
|
||||
expect(groups, hasLength(1), reason: 'one hub, one node');
|
||||
|
||||
// ── The hub, not a booking's address ──
|
||||
//
|
||||
// A logistics day starts and ends at one building: it assigns the day's
|
||||
// bookings, hands over the route, and the rider accepts the lot before he
|
||||
// leaves. Reading the head off the stops titled it with whichever address
|
||||
// sorted first — right for a drop, which is loaded at the depot, and wrong
|
||||
// for a collection, which is not. See [RiderHub].
|
||||
expect(groups.single.name, 'H1 — Peelamedu Hub');
|
||||
expect(groups.single.stops, hasLength(3));
|
||||
expect(
|
||||
groups.single.flat,
|
||||
isFalse,
|
||||
reason: 'a place holding orders opens a list; it is not the stop itself',
|
||||
);
|
||||
|
||||
// No street under it. The locations table names the hub and gives no
|
||||
// address, and a booking's road printed there reads as the hub's own.
|
||||
expect(groups.single.location, isEmpty);
|
||||
});
|
||||
|
||||
test('a collection belongs to the hub run too', () {
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
final groups = TripCard(
|
||||
trip: Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [collect('9', 'Sri Balaji Stores')],
|
||||
),
|
||||
).routeGroups();
|
||||
|
||||
expect(groups, hasLength(1));
|
||||
|
||||
// A collection is picked up at the customer's door, but it is still a
|
||||
// booking the hub handed this rider and it is accepted at the hub with the
|
||||
// rest. So it sits under the same head, with the customer on its own rung.
|
||||
expect(groups.single.flat, isFalse);
|
||||
expect(groups.single.name, 'H1 — Peelamedu Hub');
|
||||
expect(groups.single.manifest.single.customer, 'Sri Balaji Stores');
|
||||
});
|
||||
|
||||
test('a mixed route is still one hub run', () {
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
final groups = TripCard(
|
||||
trip: Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [
|
||||
drop('1', 'Joe'),
|
||||
drop('2', 'Arun'),
|
||||
collect('9', 'Sri Balaji Stores'),
|
||||
],
|
||||
),
|
||||
).routeGroups();
|
||||
|
||||
// Drops and collections alike: one building assigned them, so one node
|
||||
// holds them and each rung says which job it is — see the `D`/`P`/`P&D`
|
||||
// tags in `route_timeline.dart`.
|
||||
expect(groups, hasLength(1));
|
||||
expect(groups.single.name, 'H1 — Peelamedu Hub');
|
||||
expect(groups.single.stops, hasLength(3));
|
||||
});
|
||||
|
||||
test('a milk round is unchanged — it grouped on the line, and still does', () {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
final groups = TripCard(
|
||||
trip: Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [
|
||||
{
|
||||
'orderid': '1',
|
||||
'pickupid': 'K1',
|
||||
'kitchenid': 'K1',
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'pickupaddress': '12, SNS Colony, Peelamedu',
|
||||
'pickupcustomer': 'Joe Mathew',
|
||||
'orderstatus': 'pending',
|
||||
},
|
||||
{
|
||||
'orderid': '2',
|
||||
'pickupid': 'K1',
|
||||
'kitchenid': 'K1',
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'pickupaddress': '12, SNS Colony, Peelamedu',
|
||||
'pickupcustomer': 'Arun Prakash',
|
||||
'orderstatus': 'pending',
|
||||
},
|
||||
],
|
||||
),
|
||||
).routeGroups();
|
||||
|
||||
expect(groups, hasLength(1));
|
||||
expect(groups.single.name, 'Vidhya Kitchen');
|
||||
expect(groups.single.stops, hasLength(2));
|
||||
});
|
||||
|
||||
// ── The run-level select-all survives a mixed route ──
|
||||
//
|
||||
// `_flatSelectIds` bailed on `groups.every((g) => g.flat)`, which was right
|
||||
// while a route was one shape or the other. Grouping the drops made a
|
||||
// logistics route *both*, so the guard removed the only way to take the
|
||||
// collections in one go — on exactly the routes that have the most of them.
|
||||
group('taking the collections in one go', () {
|
||||
Future<void> pump(
|
||||
WidgetTester tester,
|
||||
List<Map<String, dynamic>> stops, {
|
||||
Set<String> selected = const {},
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(390 * 3, 900 * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: TripCard(
|
||||
trip: Trip.fromStops(id: 't1', stops: stops),
|
||||
selectedIds: selected,
|
||||
onToggleSelect: (_) {},
|
||||
onSelectAll: (_) {},
|
||||
).buildStops(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a logistics run has one, and it is the hub\'s', (
|
||||
tester,
|
||||
) async {
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
await pump(
|
||||
tester,
|
||||
[
|
||||
drop('1', 'Joe'),
|
||||
drop('2', 'Arun'),
|
||||
collect('8', 'Sri Balaji Stores'),
|
||||
collect('9', 'Kumar Traders'),
|
||||
],
|
||||
// The head control only appears once something is ticked — it is a
|
||||
// selection-mode helper, not a permanent row.
|
||||
selected: const {'8'},
|
||||
);
|
||||
|
||||
// ── One control, over the whole run ──
|
||||
//
|
||||
// The rider accepts the day's bookings at the hub before he leaves it,
|
||||
// and every booking is under the hub's node — so there is one select-all
|
||||
// and it covers the lot. The run-level control that sits outside the
|
||||
// groups is for stops with no place above them to hold one, and a
|
||||
// logistics run has none.
|
||||
expect(find.byType(SelectAllAction), findsOneWidget);
|
||||
expect(find.text('1 of 4 selected'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a milk round is unchanged — it has no flat groups', (
|
||||
tester,
|
||||
) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
await pump(
|
||||
tester,
|
||||
[
|
||||
{
|
||||
'orderid': '1',
|
||||
'pickupid': 'K1',
|
||||
'kitchenid': 'K1',
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'pickupaddress': '12, SNS Colony, Peelamedu',
|
||||
'pickupcustomer': 'Joe Mathew',
|
||||
'orderstatus': 'pending',
|
||||
},
|
||||
{
|
||||
'orderid': '2',
|
||||
'pickupid': 'K1',
|
||||
'kitchenid': 'K1',
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'pickupaddress': '12, SNS Colony, Peelamedu',
|
||||
'pickupcustomer': 'Arun Prakash',
|
||||
'orderstatus': 'pending',
|
||||
},
|
||||
],
|
||||
selected: const {'1'},
|
||||
);
|
||||
|
||||
// Every group is a counter, so there is nothing for a run-level control
|
||||
// to be over: the select-all belongs to the kitchen and there is exactly
|
||||
// **one** of it. Before the fix this was also one, and it must stay one —
|
||||
// the guard that was removed never applied to a route with no flat groups.
|
||||
expect(find.byType(SelectAllAction), findsOneWidget);
|
||||
expect(find.text('1 of 2 selected'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -136,11 +136,19 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('a picked parcel booking is closed; a picked milk run is not', () {
|
||||
// Collecting IS the job on logistics — the hub delivers it. On a round it
|
||||
// is the middle of the morning.
|
||||
test('a picked parcel stays in custody until the backend routes it', () {
|
||||
// ── Was: "a picked parcel booking is closed" ──
|
||||
//
|
||||
// That was the line-level assumption `endsAtHub` encoded, and it closed
|
||||
// hyperlocal parcels the rider was still carrying. Collection is no
|
||||
// longer an ending on either line: it is the moment the parcel becomes
|
||||
// physically his, and where it goes next is a per-order answer from the
|
||||
// server. With nothing on the row to read, it stays his.
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
expect(WorkBoundary.domainOf(stop('1004', 'picked')), WorkDomain.closed);
|
||||
expect(
|
||||
WorkBoundary.domainOf(stop('1004', 'picked')),
|
||||
WorkDomain.delivery,
|
||||
);
|
||||
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
expect(
|
||||
|
||||
290
test/next_leg_test.dart
Normal file
290
test/next_leg_test.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -35,13 +35,37 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
test('no rider-facing string says "hub"', () {
|
||||
// A comment, or the backend's own wire spelling.
|
||||
// A comment.
|
||||
bool exemptLine(String line) {
|
||||
final l = line.trimLeft();
|
||||
if (l.startsWith('//') || l.startsWith('///') || l.startsWith('*')) {
|
||||
return true;
|
||||
}
|
||||
return l.contains('inwarded_at_hub') || l.contains('Inwarded_at_Hub');
|
||||
return l.startsWith('//') || l.startsWith('///') || l.startsWith('*');
|
||||
}
|
||||
|
||||
/// True when a quoted literal is the backend's vocabulary rather than a
|
||||
/// sentence somebody reads.
|
||||
///
|
||||
/// ── Why a shape and not a list ──
|
||||
///
|
||||
/// This was a list of two spellings — `inwarded_at_hub` and its capitalised
|
||||
/// twin — and the base-handover work immediately outgrew it: a route path
|
||||
/// (`/miler/consignments/:id/inward-at-hub`), a mutation-guard key and an
|
||||
/// idempotency verb all carry the word and none of them can reach a screen.
|
||||
/// Adding four more strings would have meant adding a fifth next time.
|
||||
///
|
||||
/// The shape is the real rule. **Machine text has a separator and no
|
||||
/// spaces**: `inward_at_hub`, `inward-at-hub`, and any path containing one.
|
||||
/// **Copy is prose** — it has spaces in it, because it is a sentence. That
|
||||
/// distinction is what this test has always actually been about, and it now
|
||||
/// says so directly instead of enumerating exceptions.
|
||||
///
|
||||
/// A bare word with neither a separator nor a space stays a violation, so
|
||||
/// `Text('Hub')` is still caught. Anything parsing that value compares
|
||||
/// against the enum's own `name` instead of respelling it — see
|
||||
/// [PickupSource.of].
|
||||
bool isWireToken(String literal) {
|
||||
final t = literal.trim();
|
||||
if (t.isEmpty || t.contains(' ')) return false;
|
||||
return t.contains('_') || t.contains('-') || t.contains('/');
|
||||
}
|
||||
|
||||
/// True while the scan is inside a `debugPrint(...)` / `logGap(...)` call.
|
||||
@@ -90,6 +114,7 @@ void main() {
|
||||
if (exemptLine(lines[i])) continue;
|
||||
for (final m in quoted.allMatches(lines[i])) {
|
||||
final literal = m.group(2) ?? '';
|
||||
if (isWireToken(literal)) continue;
|
||||
if (word.hasMatch(literal)) {
|
||||
offenders.add('${e.path}:${i + 1} "$literal"');
|
||||
}
|
||||
|
||||
@@ -177,10 +177,33 @@ void main() {
|
||||
expect(group.flat, isTrue);
|
||||
});
|
||||
|
||||
test('a parcel route is always flat, whatever the address says', () {
|
||||
// Every stop is its own group there — the rider really does ride to each
|
||||
// customer for one consignment, and a header plus a single child would
|
||||
// print the same name twice.
|
||||
test('a parcel COLLECTION is a place, headed by its address', () {
|
||||
// ── The parcel line draws places now, like the milk run ──
|
||||
//
|
||||
// Home used to give logistics a flat node per stop, titled with the
|
||||
// customer. It is one shape on both lines now: the header names *where*
|
||||
// the work is and the rail under it names *who* — asked for directly,
|
||||
// against screenshots of the two screens side by side.
|
||||
//
|
||||
// Three collections at one address are one place holding three orders —
|
||||
// which is the case the old shape got most wrong, drawing the same street
|
||||
// three times.
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
final groups = TripCard(
|
||||
trip: Trip.fromStops(
|
||||
id: 'T1',
|
||||
stops: [
|
||||
for (var i = 1; i <= 3; i++) {...order(i), 'type': 'pickup'},
|
||||
],
|
||||
),
|
||||
).routeGroups();
|
||||
|
||||
expect(groups.length, 1);
|
||||
expect(groups.single.flat, isFalse);
|
||||
expect(groups.single.manifest.length, 3);
|
||||
});
|
||||
|
||||
test('but its deliveries gather under the hub they were loaded at', () {
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
final groups = TripCard(
|
||||
trip: Trip.fromStops(
|
||||
@@ -189,8 +212,9 @@ void main() {
|
||||
),
|
||||
).routeGroups();
|
||||
|
||||
expect(groups.length, 3);
|
||||
expect(groups.every((g) => g.flat), isTrue);
|
||||
expect(groups.length, 1, reason: 'one shelf, one node');
|
||||
expect(groups.single.flat, isFalse);
|
||||
expect(groups.single.manifest.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -254,14 +254,34 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('collected ends the job on a line that ends at the hub', () {
|
||||
// Terminal here, unlike on the milk run: the parcel becomes a consignment
|
||||
// and the hub carries it from there.
|
||||
expect(WorkBoundary.isClosed(order('P1', status: 'picked')), isTrue);
|
||||
test('collected no longer ends the job by line default', () {
|
||||
// ── This assertion used to be the opposite, and it was the bug ──
|
||||
//
|
||||
// `isClosed` read `isPicked && ServiceProfile.active.endsAtHub`, which is
|
||||
// always true on logistics, so **every** collected parcel was written off
|
||||
// at the pickup — including a hyperlocal one the pivot had just released
|
||||
// into this rider's own hands. He left the sender's door carrying a
|
||||
// parcel his app had already filed as finished.
|
||||
//
|
||||
// Where a collected parcel goes next is the backend's decision, per
|
||||
// order, and this row carries no answer yet: no consignment state and no
|
||||
// recorded pivot instruction. So it resolves to [NextLeg.unknown], which
|
||||
// keeps custody rather than guessing — the parcel is in his hands, and
|
||||
// the honest screen is the work tab, not Activity. See [NextLegResolver].
|
||||
expect(WorkBoundary.isClosed(order('P1', status: 'picked')), isFalse);
|
||||
expect(
|
||||
WorkBoundary.domainOf(order('P1', status: 'picked')),
|
||||
WorkDomain.closed,
|
||||
WorkDomain.delivery,
|
||||
);
|
||||
});
|
||||
|
||||
test('the backend closing it is what closes it', () {
|
||||
// `Inwarded_at_Hub` is the hub taking the parcel into its own custody.
|
||||
// That is a real ending, and it comes from the server.
|
||||
final handed = order('P1', status: 'picked')
|
||||
..['consignmentstatus'] = 'Inwarded_at_Hub';
|
||||
expect(WorkBoundary.isClosed(handed), isTrue);
|
||||
expect(WorkBoundary.domainOf(handed), WorkDomain.closed);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -246,20 +246,31 @@ void main() {
|
||||
testWidgets('a step states who it is for, and no stop type', (tester) async {
|
||||
await pumpCard(tester);
|
||||
|
||||
// This is a parcel route, so each stop is its own node and what names it is
|
||||
// the customer plus its position in the run. (The `who` + `which bag` pair
|
||||
// is the kitchen-group shape and is held by `one_bag_per_order_test.dart`.)
|
||||
// ── The parcel route draws places now ──
|
||||
//
|
||||
// This read "each stop is its own node, named by the customer plus its
|
||||
// position in the run". Home is one shape on both lines now: the header
|
||||
// names *where* and the row under it names *who*, so the customer is on the
|
||||
// rung and `Stop 1` — the flat node's meta line — is gone with the flat
|
||||
// node. See `hub_pickup_group_test.dart`.
|
||||
expect(find.text('Sri Balaji Stores'), findsWidgets);
|
||||
expect(find.text('Stop 1'), findsOneWidget);
|
||||
expect(find.text('Stop 1'), findsNothing);
|
||||
|
||||
// Everything the card used to shout on every row. The type is one tap away
|
||||
// in the sheet, on the badge `StopDetailSheet._header` draws.
|
||||
for (final label in const [
|
||||
'PICKUP',
|
||||
'DELIVERY',
|
||||
'P&D',
|
||||
'PICKUP & DELIVERY',
|
||||
]) {
|
||||
// ── `P&D` came off this list ──
|
||||
//
|
||||
// The rule these labels enforce is that the route does not *shout* a
|
||||
// stop's type: `PICKUP & DELIVERY` set on every rung is a word the rider
|
||||
// re-reads forty times a shift, and the accents that came with it were a
|
||||
// second colour system competing with the brand.
|
||||
//
|
||||
// A mixed route still has to answer "am I collecting or dropping here"
|
||||
// without opening anything, so the rail now carries a one- or two-letter
|
||||
// grey tag — see `_KindTag`. It is a mark rather than a word, and it is
|
||||
// painted in the same neutral as the metric tiles, so both halves of the
|
||||
// rule above still hold. The shouted forms stay banned.
|
||||
for (final label in const ['PICKUP', 'DELIVERY', 'PICKUP & DELIVERY']) {
|
||||
expect(
|
||||
find.text(label),
|
||||
findsNothing,
|
||||
|
||||
@@ -60,7 +60,19 @@ void main() {
|
||||
/// 2 pending, no phone → the same, with nothing to call
|
||||
/// 3 rejected + phone → no tick; it is already decided
|
||||
/// 4 skipped, no phone → not listed at all
|
||||
Future<void> pumpRoute(WidgetTester tester, {String? kitchen}) async {
|
||||
/// [uniform] forces every stop to one kind, for the tests that are about a
|
||||
/// row's **state** rather than its shape.
|
||||
///
|
||||
/// A mixed route no longer draws one shape: drops gather under the hub they
|
||||
/// were loaded at and collections stay their own doors, so a rung and a node
|
||||
/// legitimately sit at different indents. See `hub_pickup_group_test.dart`.
|
||||
/// Comparing across the two measures the grouping, not the thing under test.
|
||||
Future<void> pumpRoute(
|
||||
WidgetTester tester, {
|
||||
String? kitchen,
|
||||
String? uniform,
|
||||
bool secondAddress = false,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1170, 2532);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -69,11 +81,28 @@ void main() {
|
||||
final trip = Trip.fromStops(
|
||||
id: 'mixed',
|
||||
stops: [
|
||||
stop('A', type: 'pickup', phone: '9876543210', kitchen: kitchen),
|
||||
stop('B', type: 'delivery', kitchen: kitchen),
|
||||
stop('C', type: 'combined', phone: '9876500000', kitchen: kitchen),
|
||||
stop(
|
||||
'A',
|
||||
type: uniform ?? 'pickup',
|
||||
phone: '9876543210',
|
||||
kitchen: kitchen,
|
||||
),
|
||||
{
|
||||
...stop('D', type: 'pickup', kitchen: kitchen),
|
||||
...stop('B', type: uniform ?? 'delivery', kitchen: kitchen),
|
||||
if (secondAddress) ...{
|
||||
'pickupaddress': '4, Avinashi Rd, Coimbatore',
|
||||
'kitchenid': 'K2',
|
||||
'kitchenname': 'Second Counter',
|
||||
},
|
||||
},
|
||||
stop(
|
||||
'C',
|
||||
type: uniform ?? 'combined',
|
||||
phone: '9876500000',
|
||||
kitchen: kitchen,
|
||||
),
|
||||
{
|
||||
...stop('D', type: uniform ?? 'pickup', kitchen: kitchen),
|
||||
'orderstatus': 'skipped',
|
||||
},
|
||||
],
|
||||
@@ -102,10 +131,17 @@ void main() {
|
||||
}
|
||||
|
||||
testWidgets('every node sits on one vertical axis', (tester) async {
|
||||
await pumpRoute(tester);
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
await pumpRoute(tester, kitchen: 'K1', secondAddress: true);
|
||||
|
||||
// ── A logistics run is one node, so the axes are compared on a milk round ──
|
||||
//
|
||||
// Home groups the parcel line's whole day under the rider's base now, which
|
||||
// is the point of the change but leaves a single node with nothing to
|
||||
// compare against. A kitchen line still draws one node per counter, which
|
||||
// is the shape this test was written for.
|
||||
final nodes = find.byType(PickupTimelineGroup);
|
||||
expect(nodes.evaluate().length, greaterThan(1), reason: 'a mixed route');
|
||||
expect(nodes.evaluate().length, greaterThan(1), reason: 'two counters');
|
||||
|
||||
// The spine is a fixed-width rail with the node centred in it, so this
|
||||
// holds by construction — which is exactly why it is worth pinning. The
|
||||
@@ -123,7 +159,9 @@ void main() {
|
||||
testWidgets('the rail does not move when a row changes state', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpRoute(tester);
|
||||
// One kind throughout: this is about **state**, and a mixed route now draws
|
||||
// two shapes on purpose — see [pumpRoute]'s `uniform`.
|
||||
await pumpRoute(tester, uniform: 'pickup');
|
||||
|
||||
// Two of the four are listed now. The skipped one has always been
|
||||
// Activity's row; the **rejected** one joined it — a declined stop leaves
|
||||
|
||||
@@ -102,7 +102,10 @@ void main() {
|
||||
// rather than in the grey reserved for metadata.
|
||||
final label = tester.widget<Text>(find.text('Who received it?'));
|
||||
expect(label.style!.color, ColorConstants.slateText);
|
||||
expect(label.style!.fontWeight, FontWeight.w700);
|
||||
// One grade heavier than this once read: the app was set in Poppins on a
|
||||
// scale stepped down to compensate for that face, and is back on Manrope
|
||||
// with the scale restored. See `FontConstants`.
|
||||
expect(label.style!.fontWeight, FontWeight.w800);
|
||||
|
||||
// What has not changed, and must not: it is still quieter than the stop it
|
||||
// belongs to. The customer's name is the subject of the screen; a step
|
||||
@@ -125,16 +128,27 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('an odd picker gives its last option the full row', (
|
||||
testWidgets('an odd picker keeps its last option in its column', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester, stopOf(type: 'pickup', pickupQty: 2));
|
||||
|
||||
// Three conditions. The third at half width on a line of its own is an
|
||||
// orphan; at full width it is a second row.
|
||||
// ── This used to assert the opposite, and it fought the test above ──
|
||||
//
|
||||
// The rule was that a third option at half width is an orphan, so it took
|
||||
// the full row. But the picker two tests up exists to make every option the
|
||||
// *same* width — natural-width chips packing three-then-one "reads as a
|
||||
// list that ran out rather than as a set of four equal choices" — and a
|
||||
// stretched third option is that same inequality, introduced deliberately.
|
||||
//
|
||||
// Size is emphasis. Stretching the odd one put the largest target on the
|
||||
// screen under `Damaged`: the rarest condition and the one with the worst
|
||||
// consequence, drawn twice the size of `Sealed & intact`. This set has no
|
||||
// reason to emphasise its exception, and a grid with an empty cell in its
|
||||
// last row is simply what a grid looks like.
|
||||
expect(
|
||||
chipWidth(tester, 'Damaged'),
|
||||
greaterThan(chipWidth(tester, 'Sealed & intact') * 1.8),
|
||||
closeTo(chipWidth(tester, 'Sealed & intact'), 0.5),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
@@ -21,23 +20,19 @@ import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
/// binding does not resolve at all, and every icon on Activity came out as a
|
||||
/// hollow square. One loader, so the next family added to the app is added
|
||||
/// here rather than in five harnesses that will drift apart.
|
||||
///
|
||||
/// ── Load the family the app actually reads ──
|
||||
///
|
||||
/// This registers `FontConstants.fontFamily`, so it must be fed that family's
|
||||
/// files. When the app moved off Poppins and back to Manrope, the loader still
|
||||
/// pointed at `assets/fonts/Poppins/*` — which does not fail, it registers the
|
||||
/// wrong glyphs under the right name and every reference shot becomes a
|
||||
/// picture of a typeface the app does not ship. Manrope is a single variable
|
||||
/// font, so one file covers the whole weight axis.
|
||||
Future<void> loadGoldenFonts() async {
|
||||
final poppins = FontLoader(FontConstants.fontFamily);
|
||||
for (final e in const <(String, FontWeight)>[
|
||||
('Light', FontWeight.w300),
|
||||
('Regular', FontWeight.w400),
|
||||
('Medium', FontWeight.w500),
|
||||
('SemiBold', FontWeight.w600),
|
||||
('Bold', FontWeight.w700),
|
||||
('ExtraBold', FontWeight.w800),
|
||||
('Black', FontWeight.w900),
|
||||
]) {
|
||||
final f = File('assets/fonts/Poppins/Poppins-${e.$1}.ttf');
|
||||
if (f.existsSync()) {
|
||||
poppins.addFont(f.readAsBytes().then((b) => ByteData.view(b.buffer)));
|
||||
}
|
||||
}
|
||||
await poppins.load();
|
||||
await _loadIfPresent(FontConstants.fontFamily, const [
|
||||
'assets/fonts/Manrope/Manrope-VF.ttf',
|
||||
]);
|
||||
|
||||
await _loadIfPresent('MaterialIcons', const [
|
||||
'/opt/homebrew/share/flutter/bin/cache/artifacts/material_fonts/'
|
||||
|
||||
@@ -175,4 +175,30 @@ void main() {
|
||||
await tester.tap(find.text('Trip 2'));
|
||||
expect(chosen, 1);
|
||||
});
|
||||
|
||||
// ── What the panel holds when a slot is empty ──
|
||||
//
|
||||
// Nothing, as far as this control is concerned. [TripSelector] draws the tab
|
||||
// strip and whatever body it is handed; Home hands it the selected trip's
|
||||
// brief, and for a slot the hub has not filled that is the *same* brief built
|
||||
// from an empty trip — `0 stops left` and an em dash under every figure. See
|
||||
// `trip_unassigned_brief_test.dart`.
|
||||
//
|
||||
// So there is no "empty" state in here to test, and nothing in this control
|
||||
// says `Not assigned yet` any more: that line made the panel a stub under a
|
||||
// tab twice its height, and repeated a sentence the empty state below already
|
||||
// carried.
|
||||
group('with no body', () {
|
||||
testWidgets('the control is just the tab strip', (tester) async {
|
||||
await pump(tester, trips: 1, selected: 2);
|
||||
|
||||
expect(find.text('Not assigned yet'), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byType(TripSelector)).height,
|
||||
TripSelector.height,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
109
test/trip_unassigned_brief_test.dart
Normal file
109
test/trip_unassigned_brief_test.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip_brief_strip.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip_card.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// AN UNFILLED SLOT LOOKS LIKE A FILLED ONE, AT ZERO
|
||||
///
|
||||
/// Home's trip panel used to change shape depending on whether the hub had got
|
||||
/// to the slot yet: a filled slot got the brief, an unfilled one got a single
|
||||
/// line reading `Not assigned yet`, which made the panel a 32pt stub under a
|
||||
/// tab twice its height — and repeated a sentence the illustration below it
|
||||
/// already carried.
|
||||
///
|
||||
/// The brief of an empty slot is a real brief. Every figure it draws is derived
|
||||
/// from the stops, so an empty list gives `0 stops left`, an empty progress
|
||||
/// track and an em dash under everything with nothing to report — with no
|
||||
/// second "empty" layout to keep in step with the real one.
|
||||
///
|
||||
/// What this holds is the property that makes it worth doing: the panel is the
|
||||
/// **same height** either way, so when the hub assigns the trip the figures
|
||||
/// fill in where the dashes were instead of the page changing shape under the
|
||||
/// rider's thumb.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
Trip filled() => Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
'orderid': 'S$i',
|
||||
'pickupid': 'S$i',
|
||||
'pickupcustomer': 'Sri Balaji Stores',
|
||||
'pickupaddress': '19, Thadagam Road, Coimbatore',
|
||||
'orderstatus': 'pending',
|
||||
'Quantity': '2',
|
||||
'collectionamt': '450',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
/// What `homepage.dart` builds for a slot the hub has not filled.
|
||||
Trip unassigned() => Trip.fromStops(id: 'unassigned-1', stops: const []);
|
||||
|
||||
Future<void> pump(WidgetTester tester, Trip trip) async {
|
||||
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: TripCard(trip: trip).buildSummary(onBrand: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('an unfilled slot reads zero, not empty', (tester) async {
|
||||
await pump(tester, unassigned());
|
||||
|
||||
expect(find.text('0 stops left'), findsOneWidget);
|
||||
|
||||
// The count is a real zero; the three figures that have nothing to report
|
||||
// print the em dash every other figure in the app prints, because `None`
|
||||
// set in figure type reads as data.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('—'), findsNWidgets(3));
|
||||
|
||||
// And it is still the brief, not a special case: the same four labels.
|
||||
for (final label in const [
|
||||
'Duration',
|
||||
'Distance',
|
||||
'Parcels',
|
||||
'Payment',
|
||||
]) {
|
||||
expect(find.text(label), findsOneWidget);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('and it is the same height as a filled one', (tester) async {
|
||||
// `buildSummary` returns the strip itself — [TripCard] is a builder, and
|
||||
// never enters the tree.
|
||||
await pump(tester, unassigned());
|
||||
final empty = tester.getSize(find.byType(TripBriefStrip)).height;
|
||||
|
||||
await pump(tester, filled());
|
||||
final full = tester.getSize(find.byType(TripBriefStrip)).height;
|
||||
|
||||
expect(
|
||||
empty,
|
||||
closeTo(full, 0.5),
|
||||
reason:
|
||||
'the panel must not change shape when the hub assigns the trip — the '
|
||||
'figures fill in where the dashes were, and nothing below it moves',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -189,11 +189,23 @@ void main() {
|
||||
expect(find.text('START · KITCHEN'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('gets no kitchen sections and no meal chrome', (tester) async {
|
||||
// Nothing from the meal run may leak onto a parcel route. The *source
|
||||
// pill* is data-driven and stays if a parcel payload ever names one —
|
||||
// see [StopSourceLine] — but the grouping, its counts and its bulk
|
||||
// collect are meal-flow structure and must not appear.
|
||||
testWidgets('gets no meal VOCABULARY, though it now shares the shape', (
|
||||
tester,
|
||||
) async {
|
||||
// ── What changed, and what did not ──
|
||||
//
|
||||
// This used to assert that a parcel route got no grouping at all: no
|
||||
// place header, no counts under it, the header *is* the stop. That was
|
||||
// the rule, and it has been reversed deliberately — Home draws places on
|
||||
// both lines now, because the header names *where* and the row under it
|
||||
// names *who*, and a collection needs both. Asked for directly, against
|
||||
// screenshots of the two screens side by side.
|
||||
//
|
||||
// What must still never leak is the meal run's **words**. Shape is a
|
||||
// layout decision and the two lines can share one; `meal`, `MEAL` and
|
||||
// "ready to collect" are the other line's vocabulary for the other line's
|
||||
// work, and a logistics rider must never be shown them. That is the half
|
||||
// of this test that was always the point, and it is untouched.
|
||||
await pumpRoute(
|
||||
tester,
|
||||
stops: [stop(id: '1', kitchen: 'Kitchen 1')],
|
||||
@@ -201,15 +213,11 @@ void main() {
|
||||
expect(find.text('1 meal · 1 to accept'), findsNothing);
|
||||
expect(find.textContaining('ready to collect'), findsNothing);
|
||||
expect(find.text('MEAL'), findsNothing);
|
||||
// The stop is drawn — it is just drawn flat. A parcel route has no
|
||||
// counter to group under, so the timeline is one node per stop and the
|
||||
// header *is* the stop. `PICKUP` was the card's type mark; the type is
|
||||
// progressive disclosure in the detail sheet now.
|
||||
|
||||
// The place is headed by what the payload named it, and the customer is
|
||||
// the row under it — the milk run's shape, in the parcel line's words.
|
||||
expect(find.text('Kitchen 1'), findsOneWidget);
|
||||
expect(find.text('Customer 1'), findsOneWidget);
|
||||
expect(find.text('Kitchen 1'), findsNothing);
|
||||
// "Collect 1" on the meta line is the parcel card's own count of what he
|
||||
// is picking up at this door, and stays. The heading assertions above are
|
||||
// what tell the two apart.
|
||||
});
|
||||
|
||||
test('still works bookings', () {
|
||||
|
||||
Reference in New Issue
Block a user