Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
249 lines
10 KiB
Dart
249 lines
10 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
import 'package:miler/data/accepted_store.dart';
|
||
import 'package:miler/data/route_order.dart';
|
||
import 'package:miler/data/service_day.dart';
|
||
import 'package:miler/data/service_profile.dart';
|
||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// ACTIVITY IS TODAY, IN THREE TRIPS
|
||
///
|
||
/// The page answered "everything you have ever finished, newest first", cut
|
||
/// into date bands. The rider's question is narrower and shaped differently:
|
||
/// **how did Trip 2 go.** He was given three runs this morning; he wants the
|
||
/// record back in the same three.
|
||
///
|
||
/// These pin the four properties that makes it that record rather than a feed:
|
||
/// the grouping is the hub's, the order inside a trip is the hub's, the day is
|
||
/// today's, and nothing internal leaks onto the screen.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
void main() {
|
||
TestWidgetsFlutterBinding.ensureInitialized();
|
||
|
||
setUp(() {
|
||
SharedPreferences.setMockInitialValues({'userid': 38});
|
||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||
});
|
||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||
|
||
Map<String, dynamic> done(
|
||
String id, {
|
||
int step = 0,
|
||
DateTime? at,
|
||
String status = 'delivered',
|
||
String? tripId,
|
||
String? day,
|
||
}) => {
|
||
'orderid': id,
|
||
'pickupid': int.tryParse(id) ?? id.hashCode.abs(),
|
||
'orderstatus': status,
|
||
'step': step,
|
||
'pickupcustomer': 'Customer $id',
|
||
if (tripId != null) 'tripid': tripId,
|
||
if (at != null) 'expected_pickup_time': at.toIso8601String(),
|
||
if (at != null) 'completedat': at.toIso8601String(),
|
||
'completedday': day ?? ServiceDay.today,
|
||
};
|
||
|
||
List<String> ids(List<Map<String, dynamic>> s) => [
|
||
for (final x in s) x['orderid'] as String,
|
||
];
|
||
|
||
group('grouped into the rider’s three trips', () {
|
||
test('a three-run day produces Trip 1, Trip 2 and Trip 3', () {
|
||
final trips = Trip.groupIntoTrips([
|
||
done('a', at: DateTime(2026, 8, 21, 9)),
|
||
done('b', at: DateTime(2026, 8, 21, 14)),
|
||
done('c', at: DateTime(2026, 8, 21, 19)),
|
||
]);
|
||
expect(trips.map((t) => t.tripNumber), [1, 2, 3]);
|
||
});
|
||
|
||
test('a completed stop stays under the trip it belonged to', () {
|
||
final trips = Trip.groupIntoTrips([
|
||
done('morning-1', at: DateTime(2026, 8, 21, 8)),
|
||
done('morning-2', at: DateTime(2026, 8, 21, 11)),
|
||
done('evening-1', at: DateTime(2026, 8, 21, 18)),
|
||
]);
|
||
final slots = trips.slots;
|
||
expect(ids(slots[0]!.stops), ['morning-1', 'morning-2']);
|
||
expect(slots[1], isNull, reason: 'nothing was done in the afternoon');
|
||
expect(ids(slots[2]!.stops), ['evening-1']);
|
||
});
|
||
|
||
test('the hub’s own trip id beats the clock', () {
|
||
// Two stops eleven hours apart that the hub calls one run. The day-part
|
||
// rule is a fallback for when it says nothing — never an override.
|
||
final trips = Trip.groupIntoTrips([
|
||
done('a', at: DateTime(2026, 8, 21, 8), tripId: 'T7'),
|
||
done('b', at: DateTime(2026, 8, 21, 19), tripId: 'T7'),
|
||
]);
|
||
expect(trips.length, 1);
|
||
expect(ids(trips.single.stops), ['a', 'b']);
|
||
});
|
||
|
||
test('a trip with no dates at all still lands in a slot', () {
|
||
// No timestamps anywhere. It must not vanish, and it must not be
|
||
// labelled with an internal data-quality word.
|
||
final trips = Trip.groupIntoTrips([done('a'), done('b')]);
|
||
expect(trips.single.stops, hasLength(2));
|
||
expect(trips.slots[0], isNotNull);
|
||
});
|
||
});
|
||
|
||
group('the admin sequence survives into the record', () {
|
||
test('a trip shows its stops in the hub’s order, not by finish time', () {
|
||
// Finished out of order — c first, a last — but the run was planned
|
||
// a → b → c, and the record of a planned run is the plan.
|
||
final trips = Trip.groupIntoTrips([
|
||
done('c', step: 3, at: DateTime(2026, 8, 21, 9, 5)),
|
||
done('a', step: 1, at: DateTime(2026, 8, 21, 9, 40)),
|
||
done('b', step: 2, at: DateTime(2026, 8, 21, 9, 20)),
|
||
]);
|
||
expect(ids(trips.single.stops), ['a', 'b', 'c']);
|
||
expect(RouteOrder.sequenceOf(trips.single.stops.first), 1);
|
||
});
|
||
|
||
test('grouping never re-bases the sequence per trip', () {
|
||
final trips = Trip.groupIntoTrips([
|
||
done('a5', step: 5, at: DateTime(2026, 8, 21, 15)),
|
||
done('a4', step: 4, at: DateTime(2026, 8, 21, 14)),
|
||
done('m1', step: 1, at: DateTime(2026, 8, 21, 9)),
|
||
]);
|
||
expect(ids(trips.slots[1]!.stops), ['a4', 'a5']);
|
||
expect(RouteOrder.sequenceOf(trips.slots[1]!.stops.first), 4);
|
||
});
|
||
});
|
||
|
||
group('today only, without a timer', () {
|
||
test('yesterday is not today’s activity', () {
|
||
final yesterday = ServiceDay.stamp(
|
||
DateTime.now().subtract(const Duration(days: 1)),
|
||
);
|
||
expect(ServiceDay.belongsToToday(done('a', day: yesterday)), isFalse);
|
||
expect(ServiceDay.belongsToToday(done('b')), isTrue);
|
||
});
|
||
|
||
test('the rollover needs nothing to run at midnight', () {
|
||
// The same record, asked about on two different days. Nothing was
|
||
// deleted and no timer fired — the answer is computed at read time,
|
||
// which is the only thing that survives the app being closed at 23:59.
|
||
final row = done('a', day: '2026-08-21');
|
||
expect(ServiceDay.belongsToToday(row, now: '2026-08-21'), isTrue);
|
||
expect(ServiceDay.belongsToToday(row, now: '2026-08-22'), isFalse);
|
||
});
|
||
|
||
test('a record with no date is excluded, and reported', () {
|
||
// ── This assertion was inverted, deliberately ──
|
||
//
|
||
// It used to read `isTrue`: a payload defect was held not to be evidence
|
||
// the work happened yesterday, so an undated row was admitted to today.
|
||
// That reasoning had the burden of proof backwards. `GET /miler/bookings`
|
||
// returns the rider's whole open set, so "the API sent it" says nothing
|
||
// about *when* — and this branch turned out to be the hole yesterday's
|
||
// finished bookings came through, on a screen whose entire premise is
|
||
// that everything on it happened today.
|
||
//
|
||
// A row that cannot prove it is today's is not shown today. Nothing is
|
||
// deleted: [ServiceDay.of] still answers, both local stores keep their
|
||
// records, and the backend is untouched. It is a view filter.
|
||
//
|
||
// Still not something to print on the rider's screen — he can do nothing
|
||
// about a backend row with no timestamp — so it goes to the console
|
||
// instead of to a badge. See [ServiceDay.logUndated].
|
||
final row = {'orderid': 'x', 'orderstatus': 'delivered'};
|
||
expect(ServiceDay.belongsToToday(row), isFalse);
|
||
expect(ServiceDay.of(row), '');
|
||
|
||
final reported = <Map<String, dynamic>>[];
|
||
ServiceDay.belongsToToday(row, onUndated: reported.add);
|
||
expect(reported, [row], reason: 'excluded, but never in silence');
|
||
});
|
||
|
||
test('a timestamp is used when no day stamp was written', () {
|
||
final row = {
|
||
'orderid': 'x',
|
||
'completedat': DateTime.now().toIso8601String(),
|
||
};
|
||
expect(ServiceDay.belongsToToday(row), isTrue);
|
||
});
|
||
|
||
test('the store and the reader agree on what a day is', () async {
|
||
await addCompletedBookings([done('a')], terminalStatus: 'delivered');
|
||
final filed = (await getCompletedBookings()).single;
|
||
expect(
|
||
ServiceDay.of(filed),
|
||
ServiceDay.today,
|
||
reason:
|
||
'two implementations of a date format is how a shift ends up '
|
||
'half in one day and half in the next',
|
||
);
|
||
});
|
||
});
|
||
|
||
group('nothing internal reaches the rider', () {
|
||
test('the work-type mark never says MILK', () {
|
||
// The service profile stays — it is what separates one rider's records
|
||
// from another's. What goes is printing its name on his screen.
|
||
expect(ServiceProfile.milkMan.workTypeLabel, isNot('MILK'));
|
||
expect(ServiceProfile.milkMan.workTypeLabel, 'DELIVERY');
|
||
// Logistics never had a single-type mark and still does not — its stops
|
||
// genuinely differ, so each one names itself.
|
||
expect(ServiceProfile.parcel.workTypeLabel, isNull);
|
||
expect(ServiceProfile.parcel.usesSingleWorkType, isFalse);
|
||
});
|
||
|
||
test('records are still isolated by owner, in the data', () async {
|
||
await addCompletedBookings([done('a')], terminalStatus: 'delivered');
|
||
expect(await getCompletedOrderIds(), contains('a'));
|
||
|
||
SharedPreferences.setMockInitialValues({'userid': 99});
|
||
expect(
|
||
await getCompletedOrderIds(),
|
||
isEmpty,
|
||
reason: 'separation is an ownership rule, not a label on a card',
|
||
);
|
||
});
|
||
|
||
test('but the other line’s records ARE this one’s', () async {
|
||
// Reversed 2026-09-16 with the removal of the line from [WorkScope].
|
||
// Under one-Miler this rule lost a rider his own work: a meal drop and a
|
||
// parcel collection finished in the same shift are both his, and filing
|
||
// them in two drawers meant Activity showed him half his day and no
|
||
// explanation for the other half.
|
||
//
|
||
// Ownership is still enforced — see the test above, which is the one that
|
||
// was actually protecting anything.
|
||
await addCompletedBookings([done('a')], terminalStatus: 'delivered');
|
||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||
expect(
|
||
await getCompletedOrderIds(),
|
||
contains('a'),
|
||
reason: 'one rider, one day, one history',
|
||
);
|
||
});
|
||
});
|
||
|
||
group('delivered and skipped stay different things', () {
|
||
test('a skip is filed as a skip, never as a cancellation', () async {
|
||
await addCompletedBookings(
|
||
[done('a', status: 'outfordelivery')],
|
||
cancelled: true,
|
||
terminalStatus: 'skipped',
|
||
);
|
||
final filed = (await getCompletedBookings()).single;
|
||
expect(filed['orderstatus'], 'skipped');
|
||
expect(filed['orderstatus'], isNot('cancelled'));
|
||
});
|
||
|
||
test('a skip keeps the reason the rider gave', () async {
|
||
await addSkippedBooking(done('a'), reason: 'Shutter down');
|
||
final parked = (await getSkippedBookings()).single;
|
||
expect(parked['skipreason'], 'Shutter down');
|
||
expect(parked['orderstatus'], 'skipped');
|
||
});
|
||
});
|
||
}
|