Five payload bugs, four pages behind dead rows, and one sheet
── The full-address path was reaching the Miler empty ──
`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.
Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.
The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.
── Who the Miler rings ──
One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.
Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.
── Account's rows led nowhere ──
Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.
Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.
── ONE TOUCH is one sheet ──
It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.
Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.
── Sending a second parcel ──
`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.
Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.
── Bundle ──
pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
168
test/account_rows_test.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
||||
import 'package:doormile_cx/data/doormile_api.dart';
|
||||
import 'package:doormile_cx/data/models.dart';
|
||||
import 'package:doormile_cx/state/app_scope.dart';
|
||||
import 'package:doormile_cx/state/app_state.dart';
|
||||
import 'package:doormile_cx/ui/screens/account_screen.dart';
|
||||
import 'package:doormile_cx/ui/screens/settings/about_screen.dart';
|
||||
import 'package:doormile_cx/ui/screens/settings/notifications_screen.dart';
|
||||
import 'package:doormile_cx/ui/screens/settings/payment_screen.dart';
|
||||
import 'package:doormile_cx/ui/screens/settings/support_screen.dart';
|
||||
import 'package:doormile_cx/ui/widgets/inputs.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// EVERY ROW ON ACCOUNT OPENS SOMETHING
|
||||
///
|
||||
/// Two of these rows had no `onTap` at all and three answered with a toast.
|
||||
/// All five drew a chevron, which is the affordance for "this opens a page" —
|
||||
/// so the screen was making a promise in five places and keeping it in one.
|
||||
///
|
||||
/// A tap that silently does nothing is the worst version of this, because the
|
||||
/// customer cannot tell it from a page that is slow: they press it again.
|
||||
///
|
||||
/// The check is deliberately shallow and broad. It does not care what is on
|
||||
/// each page; it cares that the tap goes somewhere, for every row, which is
|
||||
/// the thing that was wrong and the thing that quietly comes back when a row
|
||||
/// is added in a hurry.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
late AppState app;
|
||||
|
||||
setUp(() async {
|
||||
DoormileApi.overrideInstance(DevDoormileApi());
|
||||
app = AppState()
|
||||
// Straight onto the state rather than through the sign-in screens: this
|
||||
// is about what Account's rows do, and walking the OTP flow to find out
|
||||
// would make every one of these tests fail for reasons in another file.
|
||||
..customer = const Customer(
|
||||
id: 'c1',
|
||||
name: 'Joe Oommen',
|
||||
phone: '+91 98765 43210',
|
||||
email: 'joe@example.com',
|
||||
);
|
||||
await app.refreshOrders();
|
||||
});
|
||||
|
||||
tearDown(() => DoormileApi.overrideInstance(null));
|
||||
|
||||
Future<void> pumpAccount(WidgetTester tester) async {
|
||||
// A tall surface so the whole list is laid out. The default 800x600 window
|
||||
// leaves the About group below the fold, and `tap` on an off-screen widget
|
||||
// warns and misses rather than failing — which reads as "the row does not
|
||||
// work" and is the same symptom this file exists to catch.
|
||||
tester.view.physicalSize = const Size(1230, 9000);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
AppScope(
|
||||
state: app,
|
||||
child: const MaterialApp(home: AccountScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Taps a row by its label and lets whatever it opens settle.
|
||||
Future<void> tapRow(WidgetTester tester, String label) async {
|
||||
final row = find.ancestor(
|
||||
of: find.text(label),
|
||||
matching: find.byType(DmRow),
|
||||
);
|
||||
expect(row, findsOneWidget, reason: 'no row labelled "$label"');
|
||||
await tester.tap(row);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
group('the rows that led nowhere', () {
|
||||
testWidgets('Notifications opens its page', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Notifications');
|
||||
expect(find.byType(NotificationsScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Payment methods opens its page', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Payment methods');
|
||||
expect(find.byType(PaymentScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Help and support opens its page', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Help and support');
|
||||
expect(find.byType(SupportScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('both policy rows open About', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
|
||||
await tapRow(tester, 'Terms and policies');
|
||||
expect(find.byType(AboutScreen), findsOneWidget);
|
||||
// Under its own name, not under the other row's.
|
||||
expect(find.text('Terms and policies'), findsWidgets);
|
||||
|
||||
// Not `pageBack()`: that looks for a Material or Cupertino back button
|
||||
// and this app's bar carries its own `DmBackButton`.
|
||||
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tapRow(tester, 'About Doormile');
|
||||
expect(find.byType(AboutScreen), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('no row draws a chevron it cannot honour', () {
|
||||
testWidgets('every chevron row is tappable', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
|
||||
final rows = tester.widgetList<DmRow>(find.byType(DmRow)).toList();
|
||||
expect(rows, isNotEmpty);
|
||||
|
||||
final broken = [
|
||||
for (final row in rows)
|
||||
if (row.showChevron && row.onTap == null) row.label,
|
||||
];
|
||||
expect(
|
||||
broken,
|
||||
isEmpty,
|
||||
reason: 'these rows point at a page and swallow the tap: $broken',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the pages say only what is true', () {
|
||||
testWidgets('Notifications does not promise push', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Notifications');
|
||||
|
||||
// No push SDK is wired in — `AppState.registerPushToken` has nothing to
|
||||
// hand over — so the screen states that rather than offering a switch.
|
||||
expect(find.textContaining('not switched on yet'), findsOneWidget);
|
||||
expect(find.byType(Switch), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Payment offers nothing to add or save', (tester) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Payment methods');
|
||||
|
||||
expect(find.textContaining('Nothing is charged'), findsOneWidget);
|
||||
expect(find.textContaining('Add card'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Support shows no contact until one is configured', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpAccount(tester);
|
||||
await tapRow(tester, 'Help and support');
|
||||
|
||||
// AppConfig.supportPhone/Email are empty unless passed at build time.
|
||||
// A support line that rings nowhere is worse than none, so the block is
|
||||
// absent rather than invented.
|
||||
expect(find.text('Talk to us'), findsNothing);
|
||||
expect(find.text('Copy details'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class FakeApi extends DoormileApi {
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? contactPhone,
|
||||
String? contactName,
|
||||
String? idempotencyKey,
|
||||
}) async {
|
||||
createKeys.add(idempotencyKey);
|
||||
@@ -698,28 +699,41 @@ void main() {
|
||||
'packageCount': 3,
|
||||
});
|
||||
|
||||
// The contract carries the recipient flat on the destination, beside the
|
||||
// codes, and spells a dropped pin `latitude`/`longitude`.
|
||||
// ── Nested under `details`, with the pin as `{lat, lng}` ──
|
||||
//
|
||||
// This asserted the flat shape and `latitude`/`longitude`. Both are
|
||||
// dropped by the server without an error, which is why the whole
|
||||
// full-address path was reaching the Miler with nothing on it.
|
||||
group.details.update(recipientName: 'Meera S', building: '12/A');
|
||||
group.details.pin = const MapPin(13.085, 80.21);
|
||||
expect(group.toBookingJson(), {
|
||||
'stateCode': 'TN',
|
||||
'districtCode': 'TN-MAA',
|
||||
'packageCount': 3,
|
||||
'recipientName': 'Meera S',
|
||||
'building': '12/A',
|
||||
'latitude': 13.085,
|
||||
'longitude': 80.21,
|
||||
'details': {
|
||||
'recipientName': 'Meera S',
|
||||
'building': '12/A',
|
||||
'pin': {'lat': 13.085, 'lng': 80.21},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('a PATCH sends nulls, because null is how a field is cleared', () {
|
||||
test('a PATCH clears with an empty string, not with null', () {
|
||||
// ── The old shape could not clear anything ──
|
||||
//
|
||||
// It sent `null` for an unset field and a comment said that cleared it.
|
||||
// The server writes only non-nil values, so `null` means "leave this
|
||||
// alone" — a customer could add a landmark and never remove one. `""`
|
||||
// clears a text field and `pin {0,0}` clears the pin.
|
||||
final details = DeliveryDetails(street: '12th Main');
|
||||
final patch = details.toPatchJson();
|
||||
expect(patch['street'], '12th Main');
|
||||
expect(patch.containsKey('landmark'), isTrue);
|
||||
expect(patch['landmark'], isNull);
|
||||
expect(patch.containsKey('latitude'), isTrue);
|
||||
expect(patch['landmark'], '');
|
||||
expect(patch['instructions'], '');
|
||||
expect(patch['pin'], {'lat': 0, 'lng': 0});
|
||||
|
||||
final pinned = DeliveryDetails(street: 'x')..pin = const MapPin(13.0, 80.0);
|
||||
expect(pinned.toPatchJson()['pin'], {'lat': 13.0, 'lng': 80.0});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,20 +99,20 @@ Future<void> openSend(WidgetTester tester) async {
|
||||
Finder inSheet(Finder matching) =>
|
||||
find.descendant(of: find.byType(BottomSheet), matching: matching);
|
||||
|
||||
/// Answers the destination sheet, then the window sheet behind it.
|
||||
/// Answers both questions on the pickup sheet.
|
||||
///
|
||||
/// The destination is reached by searching rather than by opening its state:
|
||||
/// search cuts across every state from the first step, and it is the path a
|
||||
/// customer who already knows where they are sending actually takes.
|
||||
/// ── One sheet now, not two ──
|
||||
///
|
||||
/// [takeWindow] false dismisses the window sheet rather than answering it,
|
||||
/// which leaves the booking without a slot and Confirm disabled — the review
|
||||
/// screen then carries "Choose a window" as its outstanding row.
|
||||
Future<void> pickCity(
|
||||
WidgetTester tester,
|
||||
String name, {
|
||||
bool takeWindow = true,
|
||||
}) async {
|
||||
/// It used to be a destination sheet that closed so a window sheet could open
|
||||
/// behind it, and this helper drove both. `showPickupSheet` asks them on one
|
||||
/// surface, so the second half is a step rather than a new sheet and its
|
||||
/// button reads "Confirm pickup" rather than "Use 2:00 – 4:00 PM".
|
||||
///
|
||||
/// The `takeWindow: false` branch went with the change: dismissing now drops
|
||||
/// the destination too, because there is only one sheet to dismiss, so a
|
||||
/// booking that reaches Review without a slot is no longer reachable from
|
||||
/// Home. Nothing passed it.
|
||||
Future<void> pickCity(WidgetTester tester, String name) async {
|
||||
await tester.enterText(find.byType(TextField).first, name);
|
||||
await settle(tester, 250);
|
||||
// A `Text` inside the sheet, explicitly.
|
||||
@@ -136,13 +136,10 @@ Future<void> pickCity(
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
// The window sheet follows the destination on its own.
|
||||
final use = find.textContaining('Use ');
|
||||
if (takeWindow && use.evaluate().isNotEmpty) {
|
||||
await tester.tap(use.first);
|
||||
await settle(tester);
|
||||
} else if (!takeWindow) {
|
||||
await tester.tapAt(const Offset(200, 40)); // dismiss through the scrim
|
||||
// The same sheet, now asking when.
|
||||
final confirm = find.text('Confirm pickup');
|
||||
if (confirm.evaluate().isNotEmpty) {
|
||||
await tester.tap(confirm.first);
|
||||
await settle(tester);
|
||||
}
|
||||
}
|
||||
@@ -278,20 +275,13 @@ void main() {
|
||||
await tester.tap(find.text('DROP'));
|
||||
await settle(tester);
|
||||
|
||||
// Destination first, then the door — the address sheet comes on its own.
|
||||
await tester.enterText(find.byType(TextField).first, 'Chennai');
|
||||
await settle(tester, 250);
|
||||
await tester.tap(
|
||||
inSheet(
|
||||
find.byWidgetPredicate((w) => w is Text && w.data == 'Chennai'),
|
||||
).first,
|
||||
);
|
||||
await settle(tester);
|
||||
final send = find.textContaining('Send to ');
|
||||
if (send.evaluate().isNotEmpty) {
|
||||
await tester.tap(send.first);
|
||||
await settle(tester);
|
||||
}
|
||||
// ── Where and when first, the door afterwards ──
|
||||
//
|
||||
// The address sheet used to land between the destination and the window.
|
||||
// Both paths now answer where-and-when on one sheet, and the full form's
|
||||
// extra question — which door — comes after it, next to the review that
|
||||
// shows it.
|
||||
await pickCity(tester, 'Chennai');
|
||||
|
||||
expect(state.draftDetailed, isTrue);
|
||||
expect(find.text('Where in Chennai?'), findsOneWidget);
|
||||
@@ -302,9 +292,7 @@ void main() {
|
||||
await tester.tap(find.text('Save address'));
|
||||
await settle(tester);
|
||||
|
||||
// Then the window, then the review — which carries the address as a row.
|
||||
await tester.tap(find.textContaining('Use ').first);
|
||||
await settle(tester);
|
||||
// Then the review, which carries the address as a row.
|
||||
expect(find.text('DROP ADDRESS'), findsOneWidget);
|
||||
|
||||
final group = state.draftDestinations.first;
|
||||
@@ -564,16 +552,38 @@ void main() {
|
||||
await settle(tester);
|
||||
expect(state.draftContactPhone, isNull);
|
||||
|
||||
// Changed, it is the number the Miler will ring at the door.
|
||||
// ── Changed, it is a note — not the number the Miler's button dials ──
|
||||
//
|
||||
// That is what this test used to claim. The rider's number is derived by
|
||||
// the backend from the booking's **account**: `GET /miler/bookings` sends
|
||||
// one phone field and nothing the create request carries can change it.
|
||||
// The handover person travels in `remarks` instead, with a name, and both
|
||||
// this screen and Review say so rather than implying otherwise.
|
||||
await tester.tap(find.text('PICKUP'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Someone else is handing it over?'));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField).first, '9003144518');
|
||||
|
||||
// By value, not by position: the fold asks for a name first now, and
|
||||
// `.first` quietly typed the phone number into it.
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, '9876543210'),
|
||||
'9003144518',
|
||||
);
|
||||
await tester.enterText(find.byType(TextField).first, 'Meera S');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
expect(state.draftContactPhone, '+91 9003144518');
|
||||
expect(state.draftContactName, 'Meera S');
|
||||
|
||||
// Review shows the account's number as the one that will be called, and
|
||||
// the handover person beside it.
|
||||
expect(find.text('YOUR MILER WILL CALL'), findsOneWidget);
|
||||
// Whatever shape the account's number is in — the dev backend echoes the
|
||||
// typed identifier, production sends E.164 — it is the one on the card.
|
||||
expect(find.text('+91 9876543210'), findsWidgets);
|
||||
expect(find.textContaining('Meera S'), findsWidgets);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
@@ -629,36 +639,35 @@ void main() {
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the destination sheet browses state then district',
|
||||
testWidgets('the destination step is one flat list, not two levels',
|
||||
(tester) async {
|
||||
await signIn(tester);
|
||||
await openSend(tester);
|
||||
|
||||
// Step one is the states, not sixty district names.
|
||||
expect(inSheet(find.text('Tamil Nadu')), findsOneWidget);
|
||||
expect(inSheet(find.text('Kerala')), findsOneWidget);
|
||||
expect(inSheet(find.text('Chennai')), findsNothing);
|
||||
|
||||
// Step two is that state's districts.
|
||||
await tester.tap(find.text('Tamil Nadu'));
|
||||
await settle(tester);
|
||||
// ── What this replaced ──
|
||||
//
|
||||
// The sheet used to open on states and hide every city until one was
|
||||
// tapped, so the first thing a customer saw was a question about
|
||||
// geography rather than an answer about service. The old test asserted
|
||||
// that Chennai was *absent* from step one.
|
||||
//
|
||||
// Now the state is a heading and the cities are all there. The list is
|
||||
// also the answer to "where do you deliver?", which is why Home's reach
|
||||
// line opens this and not a second screen of its own.
|
||||
expect(inSheet(find.text('TAMIL NADU')), findsOneWidget);
|
||||
expect(inSheet(find.text('KERALA')), findsOneWidget);
|
||||
expect(inSheet(find.text('Chennai')), findsOneWidget);
|
||||
expect(inSheet(find.text('Coimbatore')), findsWidgets);
|
||||
expect(inSheet(find.text('Ernakulam')), findsOneWidget);
|
||||
|
||||
// Districts that are not open are never offered, and neither is a state
|
||||
// whose every district is closed.
|
||||
expect(inSheet(find.text('Madurai')), findsNothing);
|
||||
expect(inSheet(find.text('Puducherry')), findsNothing);
|
||||
|
||||
// Back out, then let search cut across states: Ernakulam is in Kerala and
|
||||
// the customer should not have to know that to find it.
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await settle(tester);
|
||||
expect(inSheet(find.text('Chennai')), findsNothing);
|
||||
|
||||
// And search still cuts across states: Ernakulam is in Kerala and the
|
||||
// customer should not have to know that to find it.
|
||||
await pickCity(tester, 'Ernakulam');
|
||||
expect(find.textContaining('Ernakulam'), findsWidgets);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('orders split into their own rows once collected', (tester) async {
|
||||
@@ -820,11 +829,12 @@ void main() {
|
||||
api.flags.networkError = false;
|
||||
await tester.tap(find.text('Retry'));
|
||||
await settle(tester);
|
||||
// It comes back on the step it failed on: the states, not a blank sheet.
|
||||
expect(find.text('Tamil Nadu'), findsOneWidget);
|
||||
await tester.tap(find.text('Tamil Nadu'));
|
||||
await settle(tester);
|
||||
// It comes back with the list it failed to load, not a blank sheet. The
|
||||
// state is a heading now — upper-cased and not a step to tap through — so
|
||||
// the cities are there without a second navigation.
|
||||
expect(find.text('TAMIL NADU'), findsOneWidget);
|
||||
expect(find.text('Coimbatore'), findsWidgets);
|
||||
expect(find.text('Chennai'), findsWidgets);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
|
||||
@@ -211,14 +211,15 @@ void main() {
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await _settle(tester);
|
||||
|
||||
// Booking: BOOK asks where, then the review screen asks the rest.
|
||||
// ── Booking: one sheet, two questions, then the review ──
|
||||
//
|
||||
// It was two sheets, and the shots followed them: states, then the
|
||||
// districts inside a state, then a separate window sheet. There are no
|
||||
// states to open now — the list is flat and the state is a heading — and
|
||||
// the window is the same sheet's second step.
|
||||
await tester.tap(find.text('ONE TOUCH'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07-destination-states');
|
||||
|
||||
await tester.tap(find.text('Tamil Nadu'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07b-destination-districts');
|
||||
await _shot(tester, '07-pickup-where');
|
||||
|
||||
await tester.tap(
|
||||
find
|
||||
@@ -230,13 +231,13 @@ void main() {
|
||||
);
|
||||
await _settle(tester);
|
||||
// The dev backend allows several destinations, so the row ticked rather
|
||||
// than closing the sheet — the choice is confirmed.
|
||||
// than advancing — the choice is confirmed.
|
||||
await tester.tap(find.textContaining('Send to ').first);
|
||||
await _settle(tester);
|
||||
// The window sheet follows the destination on its own: the customer
|
||||
// chooses their slot, it is never chosen for them.
|
||||
await _shot(tester, '09-window-sheet');
|
||||
await tester.tap(find.textContaining('Use ').first);
|
||||
// Same surface, second question. The customer chooses their slot; it is
|
||||
// never chosen for them.
|
||||
await _shot(tester, '07b-pickup-when');
|
||||
await tester.tap(find.text('Confirm pickup'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '08-send');
|
||||
|
||||
|
||||
136
test/home_reach_test.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
||||
import 'package:doormile_cx/data/doormile_api.dart';
|
||||
import 'package:doormile_cx/data/models.dart';
|
||||
import 'package:doormile_cx/state/app_scope.dart';
|
||||
import 'package:doormile_cx/state/app_state.dart';
|
||||
import 'package:doormile_cx/ui/screens/home_screen.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// HOME SAYS HOW FAR DOORMILE GOES
|
||||
///
|
||||
/// One line under the sphere, and it has now failed to appear twice for two
|
||||
/// different reasons — both silent, because an absent widget looks exactly
|
||||
/// like a widget that decided it had nothing to say:
|
||||
///
|
||||
/// 1. `cachedCities` walked every state in `statesCache`, including the ones
|
||||
/// with no open districts, and `loadCities` only ever fetches districts for
|
||||
/// the open ones. It found a hole and reported nothing cached. `loadCities`
|
||||
/// returned eleven cities and the getter returned null beside it.
|
||||
/// 2. `loadCities` fills two caches and notifies nobody, so even once the data
|
||||
/// was there Home never rebuilt to read it.
|
||||
///
|
||||
/// Neither would be caught by anything that checks the widget tree in
|
||||
/// isolation, which is why this drives the real state object.
|
||||
///
|
||||
/// ── Two ways this file can hang rather than fail ──
|
||||
///
|
||||
/// The sphere breathes and its pulses repeat forever, so Home never settles:
|
||||
/// `pumpAndSettle` does not fail here, it runs until the test times out ten
|
||||
/// minutes later, which is a slow way to learn nothing. Fixed pumps instead.
|
||||
///
|
||||
/// And the dev API answers on a real timer while the widget binding's clock is
|
||||
/// fake, so awaiting a load directly inside a `testWidgets` body waits on a
|
||||
/// delay that will never elapse. Those go through `tester.runAsync`.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
late AppState app;
|
||||
|
||||
setUp(() {
|
||||
DoormileApi.overrideInstance(DevDoormileApi());
|
||||
app = AppState()
|
||||
..customer = const Customer(
|
||||
id: 'c1',
|
||||
name: 'Joe Oommen',
|
||||
phone: '+91 98765 43210',
|
||||
email: 'joe@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() => DoormileApi.overrideInstance(null));
|
||||
|
||||
Future<void> pumpHome(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1170, 2532);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
// Inside a Scaffold, because `HomeScreen` does not carry one — it is a tab
|
||||
// root and `ShellScreen` owns the Scaffold for all three. Without it there
|
||||
// is no `Material` for the ink responses and no bounded width for the
|
||||
// greeting row, and the screen fails for two reasons that have nothing to
|
||||
// do with what is being tested.
|
||||
await tester.pumpWidget(
|
||||
AppScope(
|
||||
state: app,
|
||||
child: const MaterialApp(home: Scaffold(body: HomeScreen())),
|
||||
),
|
||||
);
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
}
|
||||
|
||||
group('the serviceable set', () {
|
||||
test('cachedCities agrees with loadCities', () async {
|
||||
expect(app.cachedCities, isNull, reason: 'nothing fetched yet');
|
||||
|
||||
final loaded = await app.loadCities();
|
||||
expect(loaded, isNotEmpty);
|
||||
expect(
|
||||
app.cachedCities?.length,
|
||||
loaded.length,
|
||||
reason: 'the synchronous view must be the same set as the async one',
|
||||
);
|
||||
});
|
||||
|
||||
test('it offers no district the picker would not', () async {
|
||||
final loaded = await app.loadCities();
|
||||
final cached = app.cachedCities!;
|
||||
|
||||
final loadedCodes = {for (final c in loaded) c.district.code};
|
||||
final cachedCodes = {for (final c in cached) c.district.code};
|
||||
expect(cachedCodes, loadedCodes);
|
||||
});
|
||||
});
|
||||
|
||||
group('the reach line', () {
|
||||
testWidgets('is absent until the cities are known', (tester) async {
|
||||
await pumpHome(tester);
|
||||
expect(find.textContaining('Delivering to'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('states the reach once they are', (tester) async {
|
||||
// `runAsync`, not a bare await: the dev API answers on a real timer and
|
||||
// the widget binding's clock is fake, so awaiting it directly inside a
|
||||
// `testWidgets` body waits for a delay that will never elapse.
|
||||
await tester.runAsync(() => app.loadCities());
|
||||
await pumpHome(tester);
|
||||
|
||||
final line = find.textContaining('Delivering to');
|
||||
expect(line, findsOneWidget);
|
||||
|
||||
final text = tester.widget<Text>(line).data!;
|
||||
final cities = app.cachedCities!;
|
||||
final states = <String>{for (final c in cities) c.state.code}.length;
|
||||
expect(text, contains('${cities.length}'));
|
||||
expect(text, contains('$states states'));
|
||||
});
|
||||
|
||||
testWidgets('a rebuild after the warm-up is what makes it appear', (
|
||||
tester,
|
||||
) async {
|
||||
// The order that used to fail: Home is already on screen when the
|
||||
// cities land. Nothing re-reads the cache unless the state says so.
|
||||
await pumpHome(tester);
|
||||
expect(find.textContaining('Delivering to'), findsNothing);
|
||||
|
||||
await tester.runAsync(() => app.loadCities());
|
||||
app.notifyListeners();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Delivering to'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -121,7 +121,7 @@ void main() {
|
||||
expect(fare.routeKm, 348.5);
|
||||
});
|
||||
|
||||
test('a booking sends the contract shape, flat and with a contact', () async {
|
||||
test('a booking nests its destination details', () async {
|
||||
final t = _api({
|
||||
'reference': 'DM-482913',
|
||||
'stage': 'booked',
|
||||
@@ -186,20 +186,40 @@ void main() {
|
||||
final body = t.sent.body;
|
||||
final pickup = body['pickup'] as Map<String, dynamic>;
|
||||
expect(pickup['lat'], 13.0827);
|
||||
expect(pickup['contactName'], 'Alex Kumar');
|
||||
expect(pickup['contactPhone'], '+919876543210');
|
||||
|
||||
// ── The pickup carries no contact ──
|
||||
//
|
||||
// It used to send `contactName` and `contactPhone` here and this test
|
||||
// asserted them. The create contract's pickup is `{title, sub, lat, lng}`;
|
||||
// extra keys are dropped without an error, and the rider's number is
|
||||
// derived by the backend from the account. Two fields written on every
|
||||
// booking and read by nobody.
|
||||
expect(pickup.containsKey('contactName'), isFalse);
|
||||
expect(pickup.containsKey('contactPhone'), isFalse);
|
||||
|
||||
final destination =
|
||||
(body['destinations'] as List).single as Map<String, dynamic>;
|
||||
expect(destination['recipientName'], 'Priya S');
|
||||
expect(destination['building'], '12/A');
|
||||
// Flat, not nested under `details`.
|
||||
expect(destination.containsKey('details'), isFalse);
|
||||
// And the note is not repeated here — it is the visit's `remarks`.
|
||||
expect(destination.containsKey('instructions'), isFalse);
|
||||
|
||||
// Per-destination instructions become the visit's one `remarks` line.
|
||||
expect(body['remarks'], 'Handle with care');
|
||||
// ── Nested, not flat ──
|
||||
//
|
||||
// The previous shape spread these across the destination and this test
|
||||
// asserted `details` was absent. The contract nests them, and a
|
||||
// destination's extra keys are dropped silently — so the whole
|
||||
// full-address path was being accepted with a 201 and thrown away.
|
||||
//
|
||||
// NOT yet confirmed against the running server. The `lat`/`lng` fix was
|
||||
// proven with two identical requests one apart; this deserves the same
|
||||
// before it is trusted in production.
|
||||
final details = destination['details'] as Map<String, dynamic>;
|
||||
expect(details['recipientName'], 'Priya S');
|
||||
expect(details['building'], '12/A');
|
||||
expect(details['street'], 'MG Road');
|
||||
// The note has its own field per destination; it is not the visit's line.
|
||||
expect(details['instructions'], 'Handle with care');
|
||||
expect(destination.containsKey('recipientName'), isFalse);
|
||||
|
||||
// `remarks` is now only the handover person, and there is none here.
|
||||
expect(body.containsKey('remarks'), isFalse);
|
||||
|
||||
// And the response — which names the state and district but sends no
|
||||
// codes — must still render as a destination.
|
||||
|
||||
157
test/repeat_booking_test.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
||||
import 'package:doormile_cx/data/doormile_api.dart';
|
||||
import 'package:doormile_cx/data/models.dart';
|
||||
import 'package:doormile_cx/state/app_scope.dart';
|
||||
import 'package:doormile_cx/state/app_state.dart';
|
||||
import 'package:doormile_cx/ui/screens/booking/send_screen.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// SENDING A SECOND PARCEL
|
||||
///
|
||||
/// One booking carries one destination while the server caps it there, so a
|
||||
/// customer with two parcels for two places books twice. What that used to
|
||||
/// cost them was the whole flow twice — confirm, walk back to Home, re-answer
|
||||
/// the door they had not moved from, the window, everything.
|
||||
///
|
||||
/// [AppState.startBookingFrom] is what makes the second one short. These pin
|
||||
/// what it carries and, more importantly, what it refuses to.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
late AppState app;
|
||||
late Booking previous;
|
||||
|
||||
setUp(() async {
|
||||
DoormileApi.overrideInstance(DevDoormileApi());
|
||||
app = AppState()
|
||||
..customer = const Customer(
|
||||
id: 'c1',
|
||||
name: 'Joe Oommen',
|
||||
phone: '+91 98765 43210',
|
||||
email: 'joe@example.com',
|
||||
);
|
||||
await app.refreshOrders();
|
||||
previous = app.orders.first;
|
||||
});
|
||||
|
||||
tearDown(() => DoormileApi.overrideInstance(null));
|
||||
|
||||
group('what a repeat carries', () {
|
||||
test('the door, always', () {
|
||||
app.startBookingFrom(previous);
|
||||
expect(app.draftPickup?.title, previous.pickup.title);
|
||||
expect(app.draftPickup?.lat, previous.pickup.lat);
|
||||
expect(app.draftPickup?.lng, previous.pickup.lng);
|
||||
});
|
||||
|
||||
test('the destination only when asked', () {
|
||||
// "Send another from here" — same door, new route.
|
||||
app.startBookingFrom(previous);
|
||||
expect(
|
||||
app.draftDestinations.first.destination.districtCode,
|
||||
isNull,
|
||||
reason: 'the customer is choosing somewhere new',
|
||||
);
|
||||
|
||||
// "Send another like this" — same door, same route.
|
||||
app.startBookingFrom(previous, keepDestinations: true);
|
||||
expect(
|
||||
app.draftDestinations.first.destination.districtCode,
|
||||
previous.destinations.first.destination.districtCode,
|
||||
);
|
||||
expect(
|
||||
app.draftDestinations.first.packageCount,
|
||||
previous.destinations.first.packageCount,
|
||||
);
|
||||
});
|
||||
|
||||
test('never the window', () {
|
||||
// ── The one field a repeat must not assume ──
|
||||
//
|
||||
// A slot fills up. Pinning the second booking to the first one's window
|
||||
// would be refused at confirm with nothing the customer could act on —
|
||||
// and it is a genuinely fresh decision anyway: the first parcel going at
|
||||
// 2pm says nothing about when they want the next visit.
|
||||
expect(previous.slotId, isNotEmpty);
|
||||
app.startBookingFrom(previous, keepDestinations: true);
|
||||
expect(app.draftSlotId, isNull);
|
||||
});
|
||||
|
||||
test('a copy, not the booking itself', () {
|
||||
app.startBookingFrom(previous, keepDestinations: true);
|
||||
app.draftDestinations.first.packageCount = 9;
|
||||
|
||||
expect(
|
||||
previous.destinations.first.packageCount,
|
||||
isNot(9),
|
||||
reason: 'editing the draft must not rewrite the order it came from',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the cap is obeyed', () {
|
||||
test('a repeat never carries more destinations than the server allows', () {
|
||||
final many = Booking(
|
||||
reference: 'DM-999999',
|
||||
pickup: previous.pickup,
|
||||
destinations: [
|
||||
for (var i = 0; i < 5; i++)
|
||||
DestinationGroup(
|
||||
destination: Destination(
|
||||
stateCode: 'TN',
|
||||
districtCode: 'TN-$i',
|
||||
stateName: 'Tamil Nadu',
|
||||
districtName: 'District $i',
|
||||
),
|
||||
),
|
||||
],
|
||||
slotId: previous.slotId,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
app.startBookingFrom(many, keepDestinations: true);
|
||||
expect(
|
||||
app.draftDestinations.length,
|
||||
lessThanOrEqualTo(app.limits.maxDestinations),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
group('the one-destination cap explains itself', () {
|
||||
/// Review with a draft on it, at whatever cap the server is advertising.
|
||||
Future<void> pumpReview(WidgetTester tester, {required int cap}) async {
|
||||
tester.view.physicalSize = const Size(1230, 9000);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
app.limits = BookingLimits(maxPackages: 20, maxDestinations: cap);
|
||||
app.startBookingFrom(previous, keepDestinations: true);
|
||||
|
||||
await tester.pumpWidget(
|
||||
AppScope(state: app, child: const MaterialApp(home: SendScreen())),
|
||||
);
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('says so when the server allows one', (tester) async {
|
||||
// ── Why this needs a test at all ──
|
||||
//
|
||||
// The dev backend advertises five, so this line never renders in the
|
||||
// screenshots or anywhere else a person would look. Production
|
||||
// advertises one. A conditional nobody exercises is a conditional that
|
||||
// quietly never fires.
|
||||
await pumpReview(tester, cap: 1);
|
||||
expect(find.textContaining('One destination per pickup'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('stays quiet when it allows more', (tester) async {
|
||||
await pumpReview(tester, cap: 5);
|
||||
expect(find.textContaining('One destination per pickup'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 164 KiB |
BIN
test/snapshots/07-pickup-where.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 186 KiB |
BIN
test/snapshots/07b-pickup-when.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 259 KiB |