Files
doormile_customer_app/test/home_reach_test.dart
Thiru-tenext c3e25feaea 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.
2026-09-29 12:31:58 +05:30

137 lines
5.5 KiB
Dart

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);
});
});
}