339 lines
14 KiB
Dart
339 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
import 'package:miler/data/service_profile.dart';
|
||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||
import 'package:miler/views/Dashboard/home/trip_card.dart';
|
||
import 'package:miler/views/Dashboard/home/trip_progress_rail.dart';
|
||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// ONE APP, TWO DAYS
|
||
///
|
||
/// The whole merge rests on one promise, in both directions:
|
||
///
|
||
/// • A rider whose login carries a **meal tenant id** gets the meal process —
|
||
/// kitchen loading, deliveries, no money, no return leg.
|
||
/// • A rider **without** one gets the parcel app exactly as it was: hub to hub,
|
||
/// bookings, cash, per-stop decisions.
|
||
///
|
||
/// The second half of that is the one that can break silently. Nobody files a
|
||
/// bug saying "the meal build looks fine"; a parcel rider losing his cash screen
|
||
/// or gaining a kitchen heading is found at a customer's door. So every meal
|
||
/// assertion below has a parcel one beside it.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
void main() {
|
||
TestWidgetsFlutterBinding.ensureInitialized();
|
||
|
||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||
|
||
Map<String, dynamic> stop({
|
||
required String id,
|
||
String status = 'ASSIGNED',
|
||
String kitchen = '',
|
||
num cash = 0,
|
||
}) => {
|
||
'orderid': id,
|
||
'step': int.parse(id),
|
||
'pickupcustomer': 'Customer $id',
|
||
'pickupaddress': '$id Main Road, RS Puram',
|
||
'orderstatus': status,
|
||
if (kitchen.isNotEmpty) 'kitchenname': kitchen,
|
||
if (kitchen.isNotEmpty) 'baglabel': 'BAG-$id',
|
||
if (cash > 0) 'collectionamt': cash,
|
||
'pickuplat': 13.08,
|
||
'pickuplon': 80.21,
|
||
};
|
||
|
||
Future<void> pumpRoute(
|
||
WidgetTester tester, {
|
||
required List<Map<String, dynamic>> stops,
|
||
}) async {
|
||
tester.view.physicalSize = const Size(1080, 2600);
|
||
tester.view.devicePixelRatio = 3.0;
|
||
addTearDown(tester.view.resetPhysicalSize);
|
||
addTearDown(tester.view.resetDevicePixelRatio);
|
||
|
||
final trip = Trip.fromStops(id: 't', stops: stops);
|
||
await tester.pumpWidget(
|
||
ScreenUtilInit(
|
||
designSize: const Size(390, 844),
|
||
builder: (_, _) => MaterialApp(
|
||
home: Scaffold(
|
||
body: SingleChildScrollView(
|
||
child: TripCard(
|
||
trip: trip,
|
||
onToggleSelect: (_) {},
|
||
onViewRoute: () {},
|
||
).buildStops(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// THE MEAL LOGIN — what a tenant id buys
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
group('a rider who logs in on a meal tenant', () {
|
||
setUp(() => ServiceProfile.setActive(ServiceProfile.meals));
|
||
|
||
testWidgets('starts at a kitchen, and never comes back to base', (
|
||
tester,
|
||
) async {
|
||
await pumpRoute(
|
||
tester,
|
||
stops: [
|
||
stop(id: '1', kitchen: 'Kitchen 1'),
|
||
stop(id: '2', kitchen: 'Kitchen 1'),
|
||
],
|
||
);
|
||
|
||
// ── Ported to the timeline ──
|
||
//
|
||
// `START · KITCHEN` was the hub row's label, and the hub row is gone with
|
||
// the cards: the run opens on one line-agnostic head instead. What that
|
||
// row was really holding is the half below, which is unchanged — a meal
|
||
// run has no return leg.
|
||
expect(find.text('TODAY’S RUN'), findsOneWidget);
|
||
// His day ends at the last subscriber's door. A return leg to a warehouse
|
||
// he has never seen is an un-completable step at the end of a route he
|
||
// has in fact finished.
|
||
expect(find.text('RETURN · BASE'), findsNothing);
|
||
expect(find.text('START · BASE'), findsNothing);
|
||
});
|
||
|
||
testWidgets('sees his route grouped under the kitchen it comes from', (
|
||
tester,
|
||
) async {
|
||
await pumpRoute(
|
||
tester,
|
||
stops: [
|
||
stop(id: '1', kitchen: 'Kitchen 1'),
|
||
stop(id: '2', kitchen: 'Kitchen 2'),
|
||
],
|
||
);
|
||
|
||
// Two kitchens, two headings — the shape of a meal day, which a flat list
|
||
// hides until he opens every card.
|
||
expect(find.text('Kitchen 1'), findsOneWidget);
|
||
expect(find.text('Kitchen 2'), findsOneWidget);
|
||
// And the bag, which is what he checks against the box in his hand.
|
||
// The label the kitchen printed, as [OrderManifest] hands it over — the
|
||
// `Bag ` prefix belonged to the card's chip and went with it, which
|
||
// stopped a printed `BAG-1` reading as `Bag BAG-1`.
|
||
expect(find.text('BAG-1'), findsOneWidget);
|
||
});
|
||
|
||
test('works deliveries, not bookings', () {
|
||
final p = ServiceProfile.active;
|
||
expect(p.workTabLabel, 'Deliveries');
|
||
expect(p.jobNounPlural, 'deliveries');
|
||
expect(p.completionVerb, 'Delivered');
|
||
});
|
||
|
||
test('hands a subscriber food — he does not collect from one', () {
|
||
// Every stop after the kitchen is a drop, whether or not the payload
|
||
// bothered to label it.
|
||
expect(stopKindOf(stop(id: '1')).isDelivery, isTrue);
|
||
});
|
||
|
||
test('is never asked for money, however the payload is priced', () {
|
||
// Subscribers pay the client monthly. A rupee figure at the door is a
|
||
// question the rider cannot answer and must not be asked.
|
||
expect(stopCollectionAmount(stop(id: '1', cash: 450)), 0);
|
||
expect(ServiceProfile.active.collectsCash, isFalse);
|
||
});
|
||
|
||
test('takes the whole assignment, not stop by stop', () {
|
||
// He cannot decline one subscriber's lunch.
|
||
expect(ServiceProfile.active.acceptsPerStop, isFalse);
|
||
});
|
||
|
||
test('photographs the crate once, at the counter', () {
|
||
expect(ServiceProfile.active.bulkLoadProof, isTrue);
|
||
});
|
||
|
||
test('keeps the order on Home until it is physically in his box', () {
|
||
// Accepting changes nothing physical — the food is still in a kitchen he
|
||
// has not reached.
|
||
expect(ServiceProfile.active.handsOffAtCollection, isTrue);
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// THE PARCEL LOGIN — nothing moved
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
group('a rider who logs in without a meal tenant', () {
|
||
setUp(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||
|
||
testWidgets('gets the base-to-base route he has always had', (tester) async {
|
||
await pumpRoute(
|
||
tester,
|
||
stops: [
|
||
stop(id: '1'),
|
||
stop(id: '2'),
|
||
],
|
||
);
|
||
|
||
// The parcel day is still a loop: he leaves the hub empty, fills up at
|
||
// customer doors and comes back. The head is shared with the meal run;
|
||
// the closing leg is what tells the two apart.
|
||
expect(find.text('TODAY’S RUN'), findsOneWidget);
|
||
expect(find.text('RETURN · BASE'), findsOneWidget);
|
||
expect(find.text('START · KITCHEN'), findsNothing);
|
||
});
|
||
|
||
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')],
|
||
);
|
||
expect(find.text('1 meal · 1 to accept'), findsNothing);
|
||
expect(find.textContaining('ready to collect'), findsNothing);
|
||
expect(find.text('MEAL'), findsNothing);
|
||
|
||
// 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);
|
||
});
|
||
|
||
test('still works bookings', () {
|
||
final p = ServiceProfile.active;
|
||
expect(p.workTabLabel, 'Bookings');
|
||
expect(p.jobNounPlural, 'bookings');
|
||
expect(p.completionVerb, 'Picked up');
|
||
});
|
||
|
||
test('still collects the cash the stop is priced at', () {
|
||
// The failure this app cannot afford: a rider at a door with no way to
|
||
// record the money he is holding.
|
||
expect(stopCollectionAmount(stop(id: '1', cash: 450)), 450);
|
||
expect(ServiceProfile.active.collectsCash, isTrue);
|
||
});
|
||
|
||
test('still decides stop by stop', () {
|
||
expect(ServiceProfile.active.acceptsPerStop, isTrue);
|
||
});
|
||
|
||
test('an unlabelled stop is still a pickup', () {
|
||
expect(stopKindOf(stop(id: '1')), StopKind.pickup);
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// THE SWITCH ITSELF
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
group('what decides which day he gets', () {
|
||
Future<ServiceProfile> loginWith(Map<String, Object> prefs) async {
|
||
SharedPreferences.setMockInitialValues(prefs);
|
||
final p = await resolveServiceProfile();
|
||
ServiceProfile.setActive(p);
|
||
return p;
|
||
}
|
||
|
||
test('a recognised meal tenant name puts him on the meal line', () async {
|
||
final p = await loginWith({'tenantname': 'DailyGrubs'});
|
||
expect(p.line, ServiceLine.milkMan);
|
||
});
|
||
|
||
test(
|
||
'no tenant, an unknown tenant, or a blank one is a parcel day',
|
||
() async {
|
||
expect((await loginWith({})).line, ServiceLine.parcel);
|
||
expect((await loginWith({'tenantid': 4242})).line, ServiceLine.parcel);
|
||
expect((await loginWith({'tenantname': ''})).line, ServiceLine.parcel);
|
||
},
|
||
);
|
||
|
||
test('the fallback direction is the safe one', () async {
|
||
// Wrong-way round, a live parcel rider loses the screen that records a
|
||
// customer's cash. This way, a meal rider sees a payment prompt he can
|
||
// dismiss.
|
||
final p = await loginWith({'tenantname': 'something-nobody-configured'});
|
||
expect(p.collectsCash, isTrue);
|
||
});
|
||
});
|
||
|
||
// ── The round does not end at a warehouse ──
|
||
//
|
||
// A logistics day is a loop: leave the hub empty, fill up at customer doors,
|
||
// return with the consignments — so "then hub" is the true last leg and the
|
||
// rider is not finished until he is there. A meal rider finishes at the last
|
||
// subscriber's door and goes home. Telling him his round ends at a building he
|
||
// has never visited is the same fiction the RETURN · HUB row was removed from
|
||
// Home for, and it survived on the progress rail.
|
||
group("the progress rail speaks each line's own language", () {
|
||
Future<String> subtitle(WidgetTester tester) async {
|
||
tester.view.physicalSize = const Size(1080, 1200);
|
||
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: TripProgressRail(
|
||
progress: const [
|
||
StopProgress.done,
|
||
StopProgress.current,
|
||
StopProgress.pending,
|
||
],
|
||
slotLabel: 'Trip 1',
|
||
remaining: const Duration(minutes: 25),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
return tester
|
||
.widgetList<Text>(find.byType(Text))
|
||
.map((t) => t.data ?? '')
|
||
.firstWhere((d) => d.contains('left'), orElse: () => '');
|
||
}
|
||
|
||
testWidgets('a meal round just ends', (tester) async {
|
||
ServiceProfile.setActive(ServiceProfile.meals);
|
||
final line = await subtitle(tester);
|
||
expect(line, contains('left'));
|
||
expect(
|
||
line,
|
||
isNot(contains('hub')),
|
||
reason: 'he goes home from the last door, not to a warehouse',
|
||
);
|
||
});
|
||
|
||
testWidgets('a parcel day is a loop, and says so', (tester) async {
|
||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||
final line = await subtitle(tester);
|
||
expect(line, contains('then base'));
|
||
});
|
||
});
|
||
}
|