production
@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miler/views/Dashboard/activity/activity_page.dart';
|
||||
import 'package:miler/views/Dashboard/profile/Profilepage.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
|
||||
import 'package:miler/data/accepted_store.dart';
|
||||
|
||||
@@ -151,11 +152,30 @@ void main() {
|
||||
// Activity were on `pureSurface`. Bookings hides its background under
|
||||
// cards; Account is bare rows, so the whole tab read a shade dirtier.
|
||||
//
|
||||
// The paper moved: the scaffold's own background is the **brand** now, so
|
||||
// it shows through the two rounded top corners of [MilerSheet], and the
|
||||
// sheet paints the page. Both halves are contracts — a tab whose ground is
|
||||
// white draws no corners at all, and a tab whose sheet is off-white is the
|
||||
// original bug one layer down — so both are asserted.
|
||||
// The paper moved: the scaffold's own background became the **brand**, so
|
||||
// it could show through the two rounded top corners [MilerSheet] used to
|
||||
// clip, and the sheet painted the page.
|
||||
//
|
||||
// ── Why the ground is no longer a fixed colour ──
|
||||
//
|
||||
// That arrangement is gone. The curve moved onto the bar, which paints the
|
||||
// page colour behind its own corners (see [milerBrandSurface]), and
|
||||
// [MilerSheet] clips at zero radius. A brand-red ground is therefore no
|
||||
// longer load-bearing — it is only invisible for as long as the sheet
|
||||
// covers every pixel of the body.
|
||||
//
|
||||
// On Activity it did not. The sheet was pushed down 30pt to make room for
|
||||
// the floating figure card, and what showed in the gap was a full-width
|
||||
// band of maroon directly under the app bar — bare down both gutters, and
|
||||
// on a day with nothing finished (no card built at all) a solid red stripe
|
||||
// across the screen. It read as a second, broken app bar.
|
||||
//
|
||||
// So the ground is asserted against the thing that actually matters: it
|
||||
// must be either the brand — fine, where the sheet covers it whole — or
|
||||
// the sheet's own colour, which is the only other way a band cannot
|
||||
// appear. What stays banned is a *third* colour, which is how a tab ends
|
||||
// up a shade dirtier than the rest. See also the Activity-specific test
|
||||
// below, which pins the case that broke.
|
||||
T? firstOf<T extends Widget>() {
|
||||
final found = <T>[];
|
||||
void visit(Element e) {
|
||||
@@ -182,13 +202,16 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
firstOf<Scaffold>()?.backgroundColor,
|
||||
ColorConstants.primary,
|
||||
reason: 'the ground behind the sheet corners must be the brand',
|
||||
);
|
||||
final sheet = firstOf<MilerSheet>();
|
||||
expect(sheet, isNotNull, reason: 'every tab sits in a MilerSheet');
|
||||
final paper = sheet!.color ?? ColorConstants.pureSurface;
|
||||
|
||||
expect(
|
||||
firstOf<Scaffold>()?.backgroundColor,
|
||||
anyOf(ColorConstants.primary, paper),
|
||||
reason:
|
||||
'a ground that is neither the brand nor the page shows as a band',
|
||||
);
|
||||
// What the tabs must agree on is the *ladder*, not one flat paper:
|
||||
// a sheet is either the working white (rows drawn straight on it —
|
||||
// Activity) or the canvas with white panels standing on it (Home,
|
||||
@@ -196,13 +219,69 @@ void main() {
|
||||
// the warm off-white that made one tab read a shade dirtier than the
|
||||
// rest.
|
||||
expect(
|
||||
sheet!.color ?? ColorConstants.pureSurface,
|
||||
anyOf(ColorConstants.pureSurface, ColorConstants.daylightSurface),
|
||||
reason: 'a sheet is a rung of the surface ladder, never an off-white',
|
||||
paper,
|
||||
anyOf(
|
||||
ColorConstants.pureSurface,
|
||||
ColorConstants.daylightSurface,
|
||||
// Activity was rebuilt against the Narrative brief, which pairs a
|
||||
// *lighter* canvas with cards that carry a border and a lift. Its
|
||||
// ground is a rung of that ladder rather than this one — see
|
||||
// [Narrative], and the note there about not mixing the two recipes.
|
||||
Narrative.canvas,
|
||||
),
|
||||
reason: 'a sheet is a rung of a surface ladder, never an off-white',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('Activity shows no brand band under the app bar', (
|
||||
tester,
|
||||
) async {
|
||||
// The regression, pinned at the layer it happened on. Activity is the one
|
||||
// tab that floats a card over the seam, so it is the one tab whose sheet
|
||||
// was inset — and an inset sheet over a brand-red ground is a red stripe.
|
||||
//
|
||||
// Its ground is the page now, so there is nothing to leak whatever the
|
||||
// sheet does, and the empty-day case — where the card is not built at all
|
||||
// and the stripe was full width — is covered by the same fact.
|
||||
T? firstOf<T extends Widget>() {
|
||||
final found = <T>[];
|
||||
void visit(Element e) {
|
||||
final w = e.widget;
|
||||
if (w is T) found.add(w);
|
||||
e.visitChildren(visit);
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.rootElement!.visitChildren(visit);
|
||||
return found.isEmpty ? null : found.first;
|
||||
}
|
||||
|
||||
SharedPreferences.setMockInitialValues({'userId': 1});
|
||||
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => const MaterialApp(home: ActivityPage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final ground = firstOf<Scaffold>()?.backgroundColor;
|
||||
expect(
|
||||
ground,
|
||||
isNot(ColorConstants.primary),
|
||||
reason: 'the 30pt maroon band under the bar was this colour',
|
||||
);
|
||||
expect(
|
||||
ground,
|
||||
firstOf<MilerSheet>()?.color ?? ColorConstants.pureSurface,
|
||||
reason: 'ground and page must match, so no inset can expose a band',
|
||||
);
|
||||
});
|
||||
|
||||
// ── A row offers one gesture, and it is the whole row ──
|
||||
//
|
||||
// The card carried a footer band: a hairline, ~22pt of gaps, the booking
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/activity/activity_page.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/data/accepted_store.dart';
|
||||
|
||||
@@ -127,46 +128,60 @@ void main() {
|
||||
// opens this screen for between stops is how his day is going, so that
|
||||
// is what the space says now.
|
||||
expect(find.text('Your recent delivery activity'), findsNothing);
|
||||
// The header is a figure now: the completed count as a numeral with
|
||||
// "delivered today" as its caption — the same treatment Home's head
|
||||
// uses, and the fixture's two finished stops are the number it shows.
|
||||
// ── The header is two figures now ──
|
||||
//
|
||||
// A count and a distance, given equal halves of the card with a hairline
|
||||
// between: what the shift produced and what it cost. `today` came off
|
||||
// both captions — the card is the day, and saying so twice inside it was
|
||||
// the word doing no work.
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
expect(find.textContaining('today'), findsOneWidget);
|
||||
// The caption is the line's own completion verb — `picked up` on the
|
||||
// parcel profile the fixture runs, `delivered` on a meal round — so the
|
||||
// assertion is on the shape rather than on one line's word.
|
||||
expect(
|
||||
find.text(ServiceProfile.active.completionVerb.toLowerCase()),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
for (final f in const ['All', 'Active', 'Completed', 'Cancelled']) {
|
||||
expect(find.text(f), findsOneWidget, reason: '$f is missing');
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('the selected filter is a rule, not a chip', (tester) async {
|
||||
testWidgets('the selected filter is one travelling pill', (tester) async {
|
||||
await pump(tester, completed: [done('A1', 'Joe Mathew')]);
|
||||
|
||||
// ── This assertion moved down a level, deliberately ──
|
||||
// ── This assertion has moved three times, and the principle held ──
|
||||
//
|
||||
// It used to require exactly one *filled chip*, which was already the
|
||||
// quiet version: a bar where all four are outlined is four boxes
|
||||
// arguing about which one is on. But a filled pill is still a container,
|
||||
// and on a page whose brief is that containers recede it was the
|
||||
// loudest object above the list.
|
||||
// It began as "exactly one *filled chip*", against a bar where all four
|
||||
// were outlined — four boxes arguing about which one is on. It became "a
|
||||
// rule, not a chip" when the page's brief was that containers recede.
|
||||
// Then a segmented control, with the selected state a physical object
|
||||
// sliding along a track.
|
||||
//
|
||||
// What survives is the principle, one step further: the selected state
|
||||
// is carried by the brand colour on the word plus a 2pt rule under it,
|
||||
// and the unselected ones carry nothing at all.
|
||||
// The track is gone: it was a container drawn around a control that
|
||||
// already marks its own selection, and its equal slots forced every label
|
||||
// to the width of the longest. Four separate pills now, and the selected
|
||||
// one is filled brand with white type.
|
||||
//
|
||||
// The principle is the same one, stated a fourth time — **one** mark of
|
||||
// selection on the bar, never four.
|
||||
final selected = tester.widget<Text>(find.text('All'));
|
||||
expect(selected.style!.color, ColorConstants.primary);
|
||||
expect(selected.style!.color, Colors.white);
|
||||
expect(selected.style!.fontWeight, FontWeight.w700);
|
||||
|
||||
final other = tester.widget<Text>(find.text('Completed'));
|
||||
expect(other.style!.color, ColorConstants.secondaryText);
|
||||
expect(other.style!.color, Narrative.inkMuted);
|
||||
expect(other.style!.fontWeight, FontWeight.w600);
|
||||
|
||||
// One rule, on the selected word only.
|
||||
final rules = tester
|
||||
// Exactly one filled brand pill in the bar.
|
||||
final filled = tester
|
||||
.widgetList<AnimatedContainer>(find.byType(AnimatedContainer))
|
||||
.map((c) => c.decoration)
|
||||
.whereType<BoxDecoration>()
|
||||
.where((d) => d.color == ColorConstants.primary)
|
||||
.where((d) => d.color == Narrative.burgundy)
|
||||
.toList();
|
||||
expect(rules.length, 1);
|
||||
expect(filled, hasLength(1), reason: 'four selected filters is none');
|
||||
});
|
||||
|
||||
testWidgets('there is no chrome to operate on an empty day', (
|
||||
@@ -189,15 +204,22 @@ void main() {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
await pump(tester, completed: [done('A1', 'Joe Mathew')]);
|
||||
|
||||
// The outcome word goes quiet for the norm: the rail disc beside the
|
||||
// row already carries "delivered" in shape and colour, and a day of 25
|
||||
// green words left no colour for the row that went wrong. The word only
|
||||
// wears the status ink when the status is the exception — pinned in
|
||||
// activity_row_test's parked-stop case.
|
||||
// ── The outcome is coloured again ──
|
||||
//
|
||||
// It went quiet when every row was drawn on the same white paper and
|
||||
// colour was the only thing separating them. The rows are cards now, so
|
||||
// the separation is structural, and the design brief makes emerald *the*
|
||||
// delivered state — a status the eye can pick out without reading is the
|
||||
// difference between scanning a day and reading one. The exception keeps
|
||||
// its own ink, pinned in activity_row_test's parked-stop case.
|
||||
final word = tester.widget<Text>(find.textContaining('Delivered'));
|
||||
expect(word.style!.color, ColorConstants.secondaryText);
|
||||
expect(word.style!.color, Narrative.emerald);
|
||||
|
||||
// The rider knows a stop by who was at the door, so the name leads.
|
||||
// w600 — the brief's `title-lg`, which is what a card's own heading is
|
||||
// set at. It briefly ran at w700 while the cards had no border and no
|
||||
// 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);
|
||||
expect(
|
||||
@@ -219,7 +241,11 @@ void main() {
|
||||
expect(find.text('#A1'), findsNothing);
|
||||
|
||||
// The measurements are running text, not columns and not chips.
|
||||
//
|
||||
// One match. The hero's half prints the bare figure with `km ridden` as
|
||||
// its caption, so the only `3.2 km` on the page is this row's own.
|
||||
expect(find.text('3.2 km'), findsOneWidget);
|
||||
expect(find.text('km ridden'), findsOneWidget);
|
||||
expect(find.text('28m'), findsOneWidget);
|
||||
|
||||
// The address is detail for one stop; it belongs in the record.
|
||||
|
||||
373
test/activity_today_only_test.dart
Normal file
@@ -0,0 +1,373 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/data/service_day.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// ACTIVITY IS STRICTLY TODAY
|
||||
///
|
||||
/// The page is a shift log, not an archive, and the rule it rests on is one
|
||||
/// predicate: [ServiceDay.belongsToToday]. These are the cases that broke it.
|
||||
///
|
||||
/// ── What actually went wrong ──
|
||||
///
|
||||
/// `ServiceDay.timeKeys` used to read `deliveredat`, `updatedat` and
|
||||
/// `createdat` — three field names the backend does not send. A booking
|
||||
/// finished yesterday comes back from `GET /miler/bookings` carrying
|
||||
/// `pickedtime` and `updatedon`, neither of which was looked at, so
|
||||
/// [ServiceDay.of] returned `''`, the predicate took its "undated rows are
|
||||
/// kept" branch, and yesterday's work was admitted as today's.
|
||||
///
|
||||
/// Two things changed. The key list is now the one `happenedAt` sorts by — the
|
||||
/// same fact read out of one list instead of two — and an undated row is now
|
||||
/// **excluded and reported** rather than silently kept. The burden of proof
|
||||
/// sits with the record: a row that cannot show it belongs to today does not
|
||||
/// appear on a screen headed Today.
|
||||
///
|
||||
/// Nothing is deleted anywhere. This is a view filter, and [ServiceDay.of]
|
||||
/// still answers for any caller that legitimately wants history.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
const today = '2026-08-27';
|
||||
const yesterday = '2026-08-26';
|
||||
|
||||
/// A row as the backend sends it: no `completedday`/`skippedday` stamp,
|
||||
/// because it has never been through either local store. This is the shape
|
||||
/// that used to slip through.
|
||||
Map<String, dynamic> apiRow({
|
||||
required String id,
|
||||
required String status,
|
||||
required String at,
|
||||
String key = 'pickedtime',
|
||||
}) => <String, dynamic>{'orderid': id, 'orderstatus': status, key: at};
|
||||
|
||||
group('yesterday does not appear on today', () {
|
||||
test('a Delivered booking finished yesterday is excluded', () {
|
||||
final row = apiRow(
|
||||
id: 'D-1',
|
||||
status: 'delivered',
|
||||
// The false trailing `Z` the backend really sends — IST wall-clock in
|
||||
// a naive Postgres column, marshalled with a zone marker it never had.
|
||||
at: '2026-08-26T14:20:00Z',
|
||||
);
|
||||
|
||||
expect(stopStatusOf(row).isDelivered, isTrue);
|
||||
expect(ServiceDay.of(row), yesterday);
|
||||
expect(ServiceDay.belongsToToday(row, now: today), isFalse);
|
||||
});
|
||||
|
||||
test('a Skipped stop parked yesterday is excluded', () {
|
||||
// The skipped store stamps its own records, so this carries the
|
||||
// authoritative pair — and it still must not show today.
|
||||
final row = <String, dynamic>{
|
||||
'orderid': 'S-1',
|
||||
'orderstatus': 'skipped',
|
||||
'skippedat': '2026-08-26T19:05:00.000',
|
||||
'skippedday': yesterday,
|
||||
};
|
||||
|
||||
expect(stopStatusOf(row).isSkipped, isTrue);
|
||||
expect(ServiceDay.of(row), yesterday);
|
||||
expect(ServiceDay.belongsToToday(row, now: today), isFalse);
|
||||
});
|
||||
|
||||
test('a Cancelled booking from yesterday is excluded', () {
|
||||
final row = apiRow(
|
||||
id: 'C-1',
|
||||
status: 'cancelled',
|
||||
at: '2026-08-26T09:00:00Z',
|
||||
key: 'updatedon',
|
||||
);
|
||||
|
||||
expect(stopStatusOf(row).isCancelled, isTrue);
|
||||
expect(ServiceDay.of(row), yesterday);
|
||||
expect(ServiceDay.belongsToToday(row, now: today), isFalse);
|
||||
});
|
||||
|
||||
test('the three slices are filtered by one rule, not three', () {
|
||||
// Delivered, Skipped and Cancelled all pass through the same boundary
|
||||
// filter before the page ever slices them, which is what stops the three
|
||||
// tabs from disagreeing about what "today" means.
|
||||
final rows = [
|
||||
apiRow(id: 'D-1', status: 'delivered', at: '2026-08-26T14:20:00Z'),
|
||||
apiRow(id: 'S-1', status: 'skipped', at: '2026-08-26T19:05:00Z'),
|
||||
apiRow(id: 'C-1', status: 'cancelled', at: '2026-08-26T09:00:00Z'),
|
||||
];
|
||||
|
||||
expect(
|
||||
rows.where((r) => ServiceDay.belongsToToday(r, now: today)),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group("today's records are visible", () {
|
||||
test('a booking finished today is admitted', () {
|
||||
final row = apiRow(
|
||||
id: 'D-2',
|
||||
status: 'delivered',
|
||||
at: '2026-08-27T11:42:00Z',
|
||||
);
|
||||
|
||||
expect(ServiceDay.of(row), today);
|
||||
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
|
||||
});
|
||||
|
||||
test("this session's local completion stamp is proof enough", () {
|
||||
// `accepted_store.dart` writes `completedat` + `completedday` at the
|
||||
// moment the rider acts. That pair is the *only* record of a stop the
|
||||
// backend has not caught up with, and the strict rule must not cost it.
|
||||
final row = <String, dynamic>{
|
||||
'orderid': 'D-3',
|
||||
'orderstatus': 'picked',
|
||||
'completedat': '2026-08-27T08:15:00.000',
|
||||
'completedday': today,
|
||||
};
|
||||
|
||||
expect(ServiceDay.of(row), today);
|
||||
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
|
||||
});
|
||||
|
||||
test("this session's local skip stamp is proof enough", () {
|
||||
final row = <String, dynamic>{
|
||||
'orderid': 'S-2',
|
||||
'orderstatus': 'skipped',
|
||||
'skippedat': '2026-08-27T10:02:00.000',
|
||||
'skippedday': today,
|
||||
};
|
||||
|
||||
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
|
||||
});
|
||||
|
||||
test('a day stamp outranks a stale API timestamp on the same row', () {
|
||||
// The merge in `_load` overlays the local store onto the API row, so both
|
||||
// can be present. `dayKeys` is read first, which is what makes the
|
||||
// rider's own action the answer.
|
||||
final row = <String, dynamic>{
|
||||
'orderid': 'D-4',
|
||||
'orderstatus': 'picked',
|
||||
'updatedon': '2026-08-26T22:00:00Z',
|
||||
'completedday': today,
|
||||
};
|
||||
|
||||
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('the day boundary is IST wall-clock, not UTC', () {
|
||||
// The old `of` did `DateTime.tryParse` then `toLocal()`. On a timestamp
|
||||
// carrying the false `Z`, that shifted an IST evening by +5:30 — so a stop
|
||||
// finished at 23:50 was filed under the *next* day and vanished from
|
||||
// Activity the instant it was completed. Every evening shift lost its last
|
||||
// stop. `parseStamp` strips the marker and reads the digits the backend
|
||||
// meant, which is the day the rider would name.
|
||||
|
||||
test('23:50 stays on its own day', () {
|
||||
final row = apiRow(
|
||||
id: 'L-1',
|
||||
status: 'delivered',
|
||||
at: '2026-08-26T23:50:00Z',
|
||||
);
|
||||
|
||||
expect(ServiceDay.of(row), yesterday);
|
||||
expect(ServiceDay.belongsToToday(row, now: yesterday), isTrue);
|
||||
expect(
|
||||
ServiceDay.belongsToToday(row, now: today),
|
||||
isFalse,
|
||||
reason: 'a 23:50 stop must not leak into the next day',
|
||||
);
|
||||
});
|
||||
|
||||
test('00:10 belongs to the new day', () {
|
||||
final row = apiRow(
|
||||
id: 'L-2',
|
||||
status: 'delivered',
|
||||
at: '2026-08-27T00:10:00Z',
|
||||
);
|
||||
|
||||
expect(ServiceDay.of(row), today);
|
||||
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
|
||||
expect(ServiceDay.belongsToToday(row, now: yesterday), isFalse);
|
||||
});
|
||||
|
||||
test('a stamp with no zone marker reads the same as one with a false Z', () {
|
||||
final naive = apiRow(
|
||||
id: 'L-3',
|
||||
status: 'delivered',
|
||||
at: '2026-08-26T23:50:00',
|
||||
);
|
||||
final falseZ = apiRow(
|
||||
id: 'L-4',
|
||||
status: 'delivered',
|
||||
at: '2026-08-26T23:50:00Z',
|
||||
);
|
||||
|
||||
expect(ServiceDay.of(naive), ServiceDay.of(falseZ));
|
||||
});
|
||||
});
|
||||
|
||||
group('undated rows are excluded and reported', () {
|
||||
test('a historical row carrying no usable timestamp is excluded', () {
|
||||
final row = <String, dynamic>{
|
||||
'orderid': 'U-1',
|
||||
'orderstatus': 'delivered',
|
||||
};
|
||||
|
||||
expect(ServiceDay.of(row), isEmpty);
|
||||
expect(
|
||||
ServiceDay.belongsToToday(row, now: today),
|
||||
isFalse,
|
||||
reason: 'this branch is the hole yesterday came through',
|
||||
);
|
||||
});
|
||||
|
||||
test('exclusion is announced, not silent', () {
|
||||
final row = <String, dynamic>{'orderid': 'U-2', 'orderstatus': 'skipped'};
|
||||
final seen = <Map<String, dynamic>>[];
|
||||
|
||||
ServiceDay.belongsToToday(row, now: today, onUndated: seen.add);
|
||||
|
||||
expect(seen, [row], reason: 'a data defect worth counting');
|
||||
});
|
||||
|
||||
test('a dated row never reports itself as undated', () {
|
||||
final row = apiRow(
|
||||
id: 'U-3',
|
||||
status: 'delivered',
|
||||
at: '2026-08-27T11:42:00Z',
|
||||
);
|
||||
var reported = false;
|
||||
|
||||
ServiceDay.belongsToToday(
|
||||
row,
|
||||
now: today,
|
||||
onUndated: (_) => reported = true,
|
||||
);
|
||||
|
||||
expect(reported, isFalse);
|
||||
});
|
||||
|
||||
test('the fields the backend really sends are all read', () {
|
||||
// The regression in one line: every one of these was invisible to the
|
||||
// old key list, and each is enough on its own to date a row.
|
||||
for (final key in const [
|
||||
'pickedtime',
|
||||
'picked_time',
|
||||
'deliverytime',
|
||||
'updatedon',
|
||||
'modifiedon',
|
||||
'expected_pickup_time',
|
||||
]) {
|
||||
final row = <String, dynamic>{key: '2026-08-26T14:20:00Z'};
|
||||
expect(
|
||||
ServiceDay.of(row),
|
||||
yesterday,
|
||||
reason: '$key must date a row',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('the day turns over under an open screen', () {
|
||||
// `_load` filters against the day as read at fetch time, so the list is
|
||||
// only ever as fresh as the last fetch. [ServiceDayRollover] is what
|
||||
// notices that the wall clock has moved past it.
|
||||
|
||||
test('midnight rollover reports the old day as ended', () {
|
||||
var now = DateTime(2026, 8, 26, 23, 59);
|
||||
final day = ServiceDayRollover(clock: () => now);
|
||||
|
||||
expect(day.day, yesterday);
|
||||
expect(day.hasRolled, isFalse);
|
||||
expect(day.rollIfNeeded(), isFalse, reason: 'still the same day');
|
||||
|
||||
// The timer fires a second past midnight.
|
||||
now = DateTime(2026, 8, 27, 0, 0, 1);
|
||||
|
||||
expect(day.hasRolled, isTrue);
|
||||
expect(day.rollIfNeeded(), isTrue);
|
||||
expect(day.day, today);
|
||||
});
|
||||
|
||||
test("yesterday's rows do not survive the rollover", () {
|
||||
// What the page does with that answer: it clears state *before* the
|
||||
// refetch, so a new day starts empty even with no signal. The alternative
|
||||
// is a rider in a dead spot at 6am reading yesterday's stops under a
|
||||
// heading that says Today.
|
||||
var now = DateTime(2026, 8, 26, 22, 0);
|
||||
final day = ServiceDayRollover(clock: () => now);
|
||||
|
||||
final rows = [
|
||||
apiRow(id: 'D-1', status: 'delivered', at: '2026-08-26T14:20:00Z'),
|
||||
apiRow(id: 'S-1', status: 'skipped', at: '2026-08-26T19:05:00Z'),
|
||||
apiRow(id: 'C-1', status: 'cancelled', at: '2026-08-26T09:00:00Z'),
|
||||
];
|
||||
|
||||
expect(
|
||||
rows.where((r) => ServiceDay.belongsToToday(r, now: day.day)).length,
|
||||
3,
|
||||
);
|
||||
|
||||
now = DateTime(2026, 8, 27, 0, 0, 1);
|
||||
day.rollIfNeeded();
|
||||
|
||||
expect(
|
||||
rows.where((r) => ServiceDay.belongsToToday(r, now: day.day)),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('resume after midnight reports the old day as ended', () {
|
||||
// The timer case is the one a phone is *least* likely to deliver: both
|
||||
// platforms suspend timers in the background. This is the path that
|
||||
// actually runs — the app was backgrounded on Activity at 22:00 and the
|
||||
// rider opens it again at six.
|
||||
var now = DateTime(2026, 8, 26, 22, 0);
|
||||
final day = ServiceDayRollover(clock: () => now);
|
||||
|
||||
now = DateTime(2026, 8, 27, 6, 0);
|
||||
|
||||
expect(day.rollIfNeeded(), isTrue);
|
||||
expect(day.day, today);
|
||||
});
|
||||
|
||||
test('a resume inside the same day changes nothing', () {
|
||||
var now = DateTime(2026, 8, 27, 9, 0);
|
||||
final day = ServiceDayRollover(clock: () => now);
|
||||
|
||||
now = DateTime(2026, 8, 27, 17, 30);
|
||||
|
||||
expect(day.rollIfNeeded(), isFalse);
|
||||
expect(day.day, today);
|
||||
});
|
||||
|
||||
test('the check is idempotent, so both triggers are safe', () {
|
||||
// The timer and the resume listener both land in the same place; whichever
|
||||
// arrives second must find the day already current rather than clearing
|
||||
// the list a second time.
|
||||
var now = DateTime(2026, 8, 26, 23, 59);
|
||||
final day = ServiceDayRollover(clock: () => now);
|
||||
now = DateTime(2026, 8, 27, 0, 0, 1);
|
||||
|
||||
expect(day.rollIfNeeded(), isTrue);
|
||||
expect(day.rollIfNeeded(), isFalse);
|
||||
});
|
||||
|
||||
test('the wake lands just past the next local midnight', () {
|
||||
final day = ServiceDayRollover(
|
||||
clock: () => DateTime(2026, 8, 26, 23, 50),
|
||||
);
|
||||
|
||||
expect(day.untilNextDay, const Duration(minutes: 10, seconds: 1));
|
||||
});
|
||||
|
||||
test('the wake is correct across a month end', () {
|
||||
// `day + 1` relies on `DateTime` normalising 32 August into 1 September.
|
||||
final day = ServiceDayRollover(
|
||||
clock: () => DateTime(2026, 8, 31, 23, 30),
|
||||
);
|
||||
|
||||
expect(day.untilNextDay, const Duration(minutes: 30, seconds: 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -135,12 +135,31 @@ void main() {
|
||||
expect(ServiceDay.belongsToToday(row, now: '2026-08-22'), isFalse);
|
||||
});
|
||||
|
||||
test('a record with no date is kept, quietly', () {
|
||||
// A payload defect is not evidence the work happened yesterday, and it
|
||||
// is not something to print on the rider's screen either.
|
||||
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), isTrue);
|
||||
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', () {
|
||||
|
||||
@@ -57,7 +57,21 @@ void main() {
|
||||
// One ground now, and everything on it inverts. The wash survives as
|
||||
// `milerGlassSurface()` for the sheets, which sit over content and still
|
||||
// want it.
|
||||
testWidgets('is solid brand, so every tab shares one chrome', (
|
||||
// ── The wash gave way to the brand, and the brand grew a gradient ──
|
||||
//
|
||||
// The four tabs wore a 7% frosted brand wash flattened against white while
|
||||
// Home wore solid brand, so switching tab changed the colour of the top
|
||||
// 76pt of the phone AND the colour of the status-bar glyphs — which reads
|
||||
// as arriving in a different app rather than a different page.
|
||||
//
|
||||
// One ground now. It is [kBrandGradient] rather than a flat fill: two stops
|
||||
// about eight per cent apart, enough that the bar reads as a surface with
|
||||
// light on it and not enough to be seen as a gradient. A colour cannot be a
|
||||
// gradient, so it rides as `flexibleSpace` — and the fill underneath stays
|
||||
// the brand red, so a bar that fails to paint its surface comes out the
|
||||
// right colour rather than transparent. Both halves are asserted, because
|
||||
// it is the pair that makes the fallback safe.
|
||||
testWidgets('is one brand ground, so every tab shares one chrome', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(
|
||||
@@ -66,12 +80,23 @@ void main() {
|
||||
);
|
||||
|
||||
final bar = tester.widget<AppBar>(find.byType(AppBar));
|
||||
expect(bar.backgroundColor, ColorConstants.primary);
|
||||
expect(
|
||||
bar.flexibleSpace,
|
||||
isNull,
|
||||
reason: 'a solid ground needs no surface layered over it',
|
||||
);
|
||||
// ── The fill is transparent, and the surface paints ──
|
||||
//
|
||||
// It was the brand as a safety net behind [kBrandGradient]. It cannot be
|
||||
// one now: the bar's bottom corners are rounded so the page shows through
|
||||
// them (see [milerBrandSurface]), and an opaque rectangle behind a
|
||||
// rounded surface fills in exactly the two corners the curve exists to
|
||||
// open. What must hold is that the bar still *renders* the brand, which
|
||||
// is the gradient assertion below.
|
||||
expect(bar.backgroundColor, Colors.transparent);
|
||||
expect(bar.flexibleSpace, isNotNull);
|
||||
|
||||
final painted = tester
|
||||
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
|
||||
.map((d) => d.decoration)
|
||||
.whereType<BoxDecoration>()
|
||||
.where((d) => d.gradient == kBrandGradient);
|
||||
expect(painted, isNotEmpty, reason: 'the bar does not wear the brand');
|
||||
});
|
||||
|
||||
testWidgets('inverts everything drawn on it', (tester) async {
|
||||
@@ -106,9 +131,10 @@ void main() {
|
||||
const Scaffold(appBar: MilerAppBar(title: 'Activity')),
|
||||
);
|
||||
|
||||
// The wash is a surface, and a surface against an untinted page is
|
||||
// already an edge. A hairline under it drew that boundary a second time —
|
||||
// the same doubled-up border this app has been pulling out of its cards.
|
||||
// The brand ground is a surface, and a surface against an untinted page
|
||||
// is already an edge. A hairline under it drew that boundary a second
|
||||
// time — the same doubled-up border this app has been pulling out of its
|
||||
// cards.
|
||||
final bar = tester.widget<AppBar>(find.byType(AppBar));
|
||||
expect(bar.bottom, isNull);
|
||||
});
|
||||
@@ -140,25 +166,17 @@ void main() {
|
||||
);
|
||||
|
||||
final bar = tester.widget<AppBar>(find.byType(AppBar));
|
||||
expect(bar.backgroundColor, ColorConstants.primary);
|
||||
expect(bar.flexibleSpace, isNull);
|
||||
// ── The fill is transparent, and the surface paints ──
|
||||
//
|
||||
// It was the brand as a safety net behind [kBrandGradient]. It cannot be
|
||||
// one now: the bar's bottom corners are rounded so the page shows through
|
||||
// them (see [milerBrandSurface]), and an opaque rectangle behind a
|
||||
// rounded surface fills in exactly the two corners the curve exists to
|
||||
// open. What must hold is that the bar still *renders* the brand, which
|
||||
// is the gradient assertion below.
|
||||
expect(bar.backgroundColor, Colors.transparent);
|
||||
expect(bar.flexibleSpace, isNotNull);
|
||||
expect(bar.bottom, isNull, reason: 'no rule under the bar here either');
|
||||
});
|
||||
});
|
||||
|
||||
// The wash survives as `milerGlassSurface()`, which the SHEETS use — they sit
|
||||
// over scrolling content and genuinely want a translucent surface, where a
|
||||
// bar sits over nothing and does not. This still pins what the wash has to
|
||||
// be, wherever it is used.
|
||||
test('the wash is a brand tint light enough to carry near-black type', () {
|
||||
// Flattened against white, which is what the sheets composite it over.
|
||||
final flat = Color.alphaBlend(ColorConstants.glassRed, Colors.white);
|
||||
expect(
|
||||
flat.computeLuminance(),
|
||||
greaterThan(0.85),
|
||||
reason:
|
||||
'a saturated red surface would force white text onto a mid-tone '
|
||||
'red, the weakest contrast pairing in the palette',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
220
test/arrived_rung_test.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/data/accepted_store.dart';
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/consignment_state.dart';
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// TWO RUNGS THAT WERE NOT THERE
|
||||
///
|
||||
/// Reported from a milk run, and they are the same failure at two ends of the
|
||||
/// ladder: a rung the rider walked was not a rung the app kept.
|
||||
///
|
||||
/// 1. **Arrived did nothing.** He marked arrived at the kitchen, the row went
|
||||
/// back to ACCEPTED, and the sheet offered him arrival again. The write
|
||||
/// was fine. `POST /miler/bookings/:id/reached` answers 200 and leaves the
|
||||
/// booking on `Miler_Assigned` (request 15), so the rung lived only as a
|
||||
/// field on an in-memory row — and `_advanceStopWithSheet` finishes with
|
||||
/// `_fetchQueues()`, which rebuilt that row from the server about a second
|
||||
/// later. Nothing kept his report, so nothing showed it.
|
||||
///
|
||||
/// 2. **Picked jumped to active.** He slid to collect and the card went
|
||||
/// straight to ACTIVE without ever showing PICKED, with **Start delivery**
|
||||
/// never offered. `pickup-complete` in compatibility mode releases the
|
||||
/// consignment itself, so the stop reached the Deliveries tab already
|
||||
/// `Out_for_Delivery` — and the release gate read that as "he has set
|
||||
/// off".
|
||||
///
|
||||
/// The ladder the rider actually walks, and what answers each rung:
|
||||
///
|
||||
/// ```
|
||||
/// ACCEPTED the queue, or the accepted store
|
||||
/// ARRIVED HIS report → the arrived store (nothing else has it)
|
||||
/// PICKED pickup-complete → the collected store
|
||||
/// ACTIVE HIS press → the out-for-delivery store
|
||||
/// DELIVERED deliver
|
||||
/// ```
|
||||
///
|
||||
/// Two of the five are the rider's own and cannot be read back from anywhere.
|
||||
/// That is why they are stored, and it is the whole content of both bugs.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
});
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
/// A stop as the queue hands it over.
|
||||
Map<String, dynamic> stop(String backendStatus, {String id = '1001'}) => {
|
||||
'orderid': id,
|
||||
'pickupid': int.parse(id),
|
||||
'sourcename': 'Vidhya Kitchen',
|
||||
'orderstatus': ApiConfig.legacyStatusFromNew(backendStatus),
|
||||
'pickuplat': 11.01,
|
||||
'pickuplon': 76.95,
|
||||
'droplat': 11.05,
|
||||
'droplon': 76.99,
|
||||
};
|
||||
|
||||
group('1 · arrival is kept, because nothing else keeps it', () {
|
||||
test('the server does not persist it — this is the premise', () {
|
||||
// Not an assertion about our code: it is the reason the store exists.
|
||||
// `reached` leaves the booking where it was, so a poll cannot return the
|
||||
// rung and the app cannot read it back.
|
||||
expect(
|
||||
ApiConfig.legacyStatusFromNew('Miler_Assigned'),
|
||||
isNot('arrived'),
|
||||
reason:
|
||||
'if this ever becomes "arrived" the backend has shipped request '
|
||||
'15 and the arrived store should be deleted, not kept',
|
||||
);
|
||||
});
|
||||
|
||||
test('an arrival survives being written and read back', () async {
|
||||
await addArrivedOrderIds(['1001']);
|
||||
expect(await getArrivedOrderIds(), {'1001'});
|
||||
});
|
||||
|
||||
test(
|
||||
'the rung it produces is ARRIVED, over a queue that says accepted',
|
||||
() async {
|
||||
await addArrivedOrderIds(['1001']);
|
||||
final arrived = await getArrivedOrderIds();
|
||||
|
||||
// What `_fetchQueues` does with the set: the row comes back on
|
||||
// Miler_Assigned and the rider's own record puts it back on the rung.
|
||||
final row = stop('Miler_Assigned');
|
||||
expect(
|
||||
stopStatusFromRaw(row['orderstatus']),
|
||||
isNot(StopStatus.arrived),
|
||||
reason: 'the server really does hand it back one rung down',
|
||||
);
|
||||
|
||||
if (arrived.contains(row['orderid'])) row['orderstatus'] = 'arrived';
|
||||
|
||||
expect(
|
||||
MilkRun.stageOf(
|
||||
row,
|
||||
acceptedIds: {'1001'},
|
||||
collectedIds: const {},
|
||||
outForDeliveryIds: const {},
|
||||
),
|
||||
StopStatus.arrived,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('and it is dropped the moment the stop is collected', () async {
|
||||
await addArrivedOrderIds(['1001']);
|
||||
await removeArrivedOrderIds(['1001']);
|
||||
expect(
|
||||
await getArrivedOrderIds(),
|
||||
isEmpty,
|
||||
reason:
|
||||
'a local record that outlives its rung pins tomorrow\'s row to '
|
||||
'yesterday\'s arrival',
|
||||
);
|
||||
});
|
||||
|
||||
test('one kitchen\'s arrival does not move another\'s stop', () async {
|
||||
await addArrivedOrderIds(['1001']);
|
||||
expect(await getArrivedOrderIds(), isNot(contains('2002')));
|
||||
});
|
||||
});
|
||||
|
||||
group('2 · picked is not active until he says so', () {
|
||||
test('compatibility mode really does release at the counter', () {
|
||||
// The premise of the second bug, stated as the contract does: a
|
||||
// hyperlocal booking is `Out_for_Delivery` the moment it is collected.
|
||||
expect(consignmentStateFromRaw('Out_for_Delivery').isDeliverable, isTrue);
|
||||
expect(
|
||||
consignmentStateFromRaw('Out_for_Delivery').needsRelease,
|
||||
isFalse,
|
||||
reason:
|
||||
'which is exactly why reading needsRelease first skipped the rung '
|
||||
'— the consignment does not need releasing, the RIDER has not '
|
||||
'released it',
|
||||
);
|
||||
});
|
||||
|
||||
test('the card holds at PICKED while the round is unstarted', () {
|
||||
expect(
|
||||
MilkRun.stageOf(
|
||||
stop('Out_for_Delivery'),
|
||||
acceptedIds: {'1001'},
|
||||
collectedIds: {'1001'},
|
||||
outForDeliveryIds: const {},
|
||||
),
|
||||
StopStatus.picked,
|
||||
);
|
||||
});
|
||||
|
||||
test('and moves to ACTIVE only on his own press', () {
|
||||
expect(
|
||||
MilkRun.stageOf(
|
||||
stop('Out_for_Delivery'),
|
||||
acceptedIds: {'1001'},
|
||||
collectedIds: {'1001'},
|
||||
outForDeliveryIds: {'1001'},
|
||||
),
|
||||
StopStatus.outForDelivery,
|
||||
);
|
||||
});
|
||||
|
||||
test('a delivered stop is never dragged back down the ladder', () {
|
||||
// The one direction the local sets must not win in: the server knows
|
||||
// something the rider's phone does not.
|
||||
expect(
|
||||
MilkRun.stageOf(
|
||||
stop('Delivered'),
|
||||
acceptedIds: {'1001'},
|
||||
collectedIds: {'1001'},
|
||||
outForDeliveryIds: const {},
|
||||
),
|
||||
StopStatus.delivered,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('3 · the ladder in order, walked once', () {
|
||||
test('accepted → arrived → picked → active', () async {
|
||||
final row = stop('Miler_Assigned');
|
||||
final collected = <String>{};
|
||||
final released = <String>{};
|
||||
|
||||
StopStatus rung() => MilkRun.stageOf(
|
||||
row,
|
||||
acceptedIds: {'1001'},
|
||||
collectedIds: collected,
|
||||
outForDeliveryIds: released,
|
||||
);
|
||||
|
||||
expect(rung(), StopStatus.accepted);
|
||||
|
||||
// He arrives.
|
||||
await addArrivedOrderIds(['1001']);
|
||||
row['orderstatus'] = 'arrived';
|
||||
expect(rung(), StopStatus.arrived);
|
||||
|
||||
// He collects. pickup-complete converts and — in compatibility mode —
|
||||
// releases; the rider's rung is still PICKED.
|
||||
await removeArrivedOrderIds(['1001']);
|
||||
row['orderstatus'] = 'picked';
|
||||
row['consignmentstatus'] = 'Out_for_Delivery';
|
||||
collected.add('1001');
|
||||
expect(rung(), StopStatus.picked);
|
||||
expect(await getArrivedOrderIds(), isEmpty);
|
||||
|
||||
// He slides to start the ride.
|
||||
released.add('1001');
|
||||
expect(rung(), StopStatus.outForDelivery);
|
||||
});
|
||||
});
|
||||
}
|
||||
161
test/arrived_stays_on_home_test.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.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';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// A STOP HE IS STANDING AT MUST NOT LEAVE THE SCREEN
|
||||
///
|
||||
/// Reported from a live run: the rider marked **Arrived**, the console updated
|
||||
/// correctly — and the stop vanished off Home. Deliveries did not have it
|
||||
/// either, and could not: arrival is a *pre-pickup* rung, and the delivery tab
|
||||
/// only takes work once it is collected. The stop existed in neither place.
|
||||
///
|
||||
/// ── What caused it ──
|
||||
///
|
||||
/// Nothing on the backend. [StopState.arrived] and [StopState.active] used to
|
||||
/// be one value, and `TripCard._showsOnHome` — like a dozen other sites — asked
|
||||
/// `state == StopState.active` to mean *is he at the counter*. Splitting the
|
||||
/// two apart so the row could say **Arrived** instead of **Active** left every
|
||||
/// one of those comparisons answering `false` for the new rung.
|
||||
///
|
||||
/// The visibility filter was the one the rider could see. The others were just
|
||||
/// as broken and quieter: the selection bar offered *Accept* for a stop he was
|
||||
/// already at, the card stopped building its **Mark as picked** button, and the
|
||||
/// group he was working no longer opened by default.
|
||||
///
|
||||
/// These pin the rung end to end — the predicate that now answers the question
|
||||
/// once ([StopStateX.isAtSource]), and the screen behaviour that regressed.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
Map<String, dynamic> meal(String id, String who, {String? status}) => {
|
||||
'orderid': id,
|
||||
'type': 'delivery',
|
||||
'step': int.parse(id),
|
||||
'pickupcustomer': who,
|
||||
'kitchenid': 'K1',
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'sourcename': 'Vidhya Kitchen',
|
||||
'pickupcontactno': '9787698259',
|
||||
'pickupaddress': '4 Cross, RS Puram, Coimbatore',
|
||||
'dropaddress': '12 SNS Colony, Peelamedu, Coimbatore 641004',
|
||||
'Quantity': '1',
|
||||
if (status != null) 'orderstatus': status,
|
||||
};
|
||||
|
||||
Future<void> pumpHome(
|
||||
WidgetTester tester,
|
||||
List<Map<String, dynamic>> stops,
|
||||
) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
tester.view.physicalSize = const Size(390 * 3, 2600 * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: TripCard(
|
||||
trip: Trip.fromStops(id: 'run', stops: stops),
|
||||
acceptedIds: const {},
|
||||
selectedIds: const {},
|
||||
onToggleSelect: (_) {},
|
||||
onSelectAll: (_) {},
|
||||
onStopTap: (_) {},
|
||||
onCallStop: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
// Not `pumpAndSettle`: an arrived row is a **live** row, and [LiveMark]
|
||||
// pulses for as long as it is on screen — settling waits for an animation
|
||||
// that is never going to stop. Two pumps lay the frame out and let the
|
||||
// pulse start, which is all these assertions need.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
group('1 · the predicate that answers it once', () {
|
||||
test('arrived and active both mean "he is at the source"', () {
|
||||
expect(StopState.arrived.isAtSource, isTrue);
|
||||
expect(StopState.active.isAtSource, isTrue);
|
||||
});
|
||||
|
||||
test('and nothing else does', () {
|
||||
for (final other in const [
|
||||
StopState.pending,
|
||||
StopState.accepted,
|
||||
StopState.collected,
|
||||
StopState.done,
|
||||
StopState.skipped,
|
||||
StopState.rejected,
|
||||
]) {
|
||||
expect(
|
||||
other.isAtSource,
|
||||
isFalse,
|
||||
reason: '$other must not read as standing at the counter',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('an arrived stop is committed work, not an open offer', () {
|
||||
// It rode in on `active` and was dropped by the same split. A rider who
|
||||
// has reported an arrival has plainly committed to the stop.
|
||||
expect(StopState.arrived.isCommitted, isTrue);
|
||||
expect(StopState.pending.isCommitted, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('2 · the screen behaviour that regressed', () {
|
||||
testWidgets('an arrived stop stays on Home', (tester) async {
|
||||
await pumpHome(tester, [
|
||||
meal('1', 'Joe Mathew', status: 'arrived'),
|
||||
meal('2', 'Arun Prakash'),
|
||||
]);
|
||||
|
||||
expect(
|
||||
find.text('Joe Mathew'),
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'the rider is standing at this counter — it cannot leave Home, '
|
||||
'and Deliveries will not take it until it is collected',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('it is still there beside an untouched stop', (tester) async {
|
||||
// Guards the opposite overcorrection: making arrived visible must not
|
||||
// have cost the ordinary rows their place in the run.
|
||||
await pumpHome(tester, [
|
||||
meal('1', 'Joe Mathew', status: 'arrived'),
|
||||
meal('2', 'Arun Prakash'),
|
||||
]);
|
||||
|
||||
expect(find.text('Joe Mathew'), findsOneWidget);
|
||||
expect(find.text('Arun Prakash'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a collected stop still leaves — that one is not a bug', (
|
||||
tester,
|
||||
) async {
|
||||
// The filter's real job, asserted so a fix for the disappearance cannot
|
||||
// quietly turn into "nothing ever leaves Home".
|
||||
await pumpHome(tester, [
|
||||
meal('1', 'Joe Mathew', status: 'picked'),
|
||||
meal('2', 'Arun Prakash'),
|
||||
]);
|
||||
|
||||
expect(find.text('Joe Mathew'), findsNothing);
|
||||
expect(find.text('Arun Prakash'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -86,13 +86,27 @@ void main() {
|
||||
//
|
||||
// So: no rule, no disclosure, one card that is always in the state the
|
||||
// rider previously had to discover. That costs height, and the budget says
|
||||
// how much — enough for a head, a divider and one row of four figures, and
|
||||
// how much — enough for a head, a bar, and the four facts two-by-two, and
|
||||
// not enough for a fifth thing to be added without a decision.
|
||||
//
|
||||
// ── 190 → 245 ──
|
||||
//
|
||||
// Raised deliberately, once, when the four journey figures came out of the
|
||||
// fold and onto the face. It buys the thing the fold was costing: a rider
|
||||
// could not see the day's duration, distance, parcel count or cash without
|
||||
// pressing something, and the press was rare enough that the figures were
|
||||
// effectively shipped and unreachable.
|
||||
//
|
||||
// The 55 points are paid for elsewhere in the same change — the header
|
||||
// collapses to 52 on scroll where it used to be a permanent 160, and the
|
||||
// run's ratio came off this card because the header states it. The guard
|
||||
// stays because the failure mode is unchanged: a card that grows a line at
|
||||
// a time until the route is below the fold again.
|
||||
testWidgets('stays within the card budget', (tester) async {
|
||||
final h = await briefHeight(tester);
|
||||
expect(
|
||||
h,
|
||||
lessThan(190),
|
||||
lessThan(245),
|
||||
reason:
|
||||
'measured $h — the brief is the permanent furniture above the '
|
||||
'route, so every point of it is charged at every scroll position',
|
||||
@@ -121,46 +135,41 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
// ── Reversed, on an explicit product call ──
|
||||
// ── Reversed twice, and this is the third position ──
|
||||
//
|
||||
// This once required the four figures ABSENT until tapped; then it was
|
||||
// flipped to require them present, on the argument that a once-a-shift
|
||||
// read behind a chevron nobody presses is information the app has and
|
||||
// the rider does not. That argument is about *discoverability* and it
|
||||
// was answered by a measurement: on a 390×844 phone the four columns
|
||||
// plus the trip tabs plus the app bar reach the fold before the first
|
||||
// kitchen, so Home opened on a dashboard about the run instead of on
|
||||
// the run.
|
||||
// The four figures were once required ABSENT until tapped, on a
|
||||
// measurement: four columns plus the trip tabs plus the app bar reached
|
||||
// the fold before the first kitchen, so Home opened on a dashboard about
|
||||
// the run instead of on the run.
|
||||
//
|
||||
// The founder's call is that the figures are reference material and the
|
||||
// route is the screen. What stays out is the progress face — stops left,
|
||||
// and cash in hand — which are the two a rider acts on mid-shift.
|
||||
// What changed is the space, not the argument. The header collapses now,
|
||||
// the tabs are a row of pills rather than a strip, and the figures are
|
||||
// two-by-two rather than four across — so the same information costs
|
||||
// about half the vertical it did when it was banished, and the fold it
|
||||
// was pushing the route past is no longer where it was.
|
||||
//
|
||||
// So they are on the face, in sentence case, with the run's ratio taken
|
||||
// OFF the card entirely — the header states that now.
|
||||
for (final label in const [
|
||||
'DURATION',
|
||||
'DISTANCE',
|
||||
'PARCELS',
|
||||
'PAYMENT',
|
||||
'Duration',
|
||||
'Distance',
|
||||
'Parcels',
|
||||
'Payment',
|
||||
]) {
|
||||
expect(
|
||||
find.text(label),
|
||||
findsNothing,
|
||||
reason: '"$label" is reference; it is one tap away, not permanent',
|
||||
findsOneWidget,
|
||||
reason: '"$label" is on the face, not one tap away',
|
||||
);
|
||||
}
|
||||
|
||||
// And the face itself is still out, unasked.
|
||||
// The face's own headline is still the thing a rider acts on.
|
||||
expect(find.textContaining('stop'), findsWidgets);
|
||||
|
||||
// One tap brings the whole set back.
|
||||
// The fold keeps what it is actually for: the shift and slot windows,
|
||||
// read once in the morning.
|
||||
await tester.tap(find.byType(TripBriefStrip));
|
||||
await tester.pumpAndSettle();
|
||||
for (final label in const ['DURATION', 'DISTANCE', 'PARCELS']) {
|
||||
expect(
|
||||
find.text(label),
|
||||
findsOneWidget,
|
||||
reason: '$label after the tap',
|
||||
);
|
||||
}
|
||||
expect(
|
||||
find.text('CURRENT SHIFT'),
|
||||
findsNothing,
|
||||
@@ -217,11 +226,18 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('Trip tabs', () {
|
||||
testWidgets('fit in one compact row', (tester) async {
|
||||
// ── The trip selector moved into the header ──
|
||||
//
|
||||
// Its height and its three-slot rule are pinned in `trip_tabs_pinned_test`,
|
||||
// against the widget's own declared height rather than against a sliver
|
||||
// extent it no longer has. What is left here is the density guard: it is
|
||||
// chrome the rider sees at every scroll position, so its height is charged
|
||||
// continuously and must not creep.
|
||||
group('Trip selector', () {
|
||||
testWidgets('stays inside its own height budget', (tester) async {
|
||||
await pump(
|
||||
tester,
|
||||
TripTabs(
|
||||
TripSelector(
|
||||
trips: [
|
||||
Trip.fromStops(id: 't1', stops: [stop('A')]),
|
||||
Trip.fromStops(id: 't2', stops: [stop('B')]),
|
||||
@@ -231,37 +247,25 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final h = tester.getSize(find.byType(TripTabs)).height;
|
||||
// ── This bound was raised deliberately, and it cost something ──
|
||||
//
|
||||
// The strip went from one line (~41pt) to a segmented control carrying a
|
||||
// label, a status line and a running-indicator (~70pt). That is ~30pt of
|
||||
// *permanent* height, and it is worse than it sounds because the bar is
|
||||
// now a pinned sliver: it is on screen at every scroll position, not just
|
||||
// at the top.
|
||||
//
|
||||
// What it buys is the separation of "the trip I am looking at" from "the
|
||||
// trip that is running", which the one-line version could not express at
|
||||
// all, plus per-trip progress in words rather than an ambiguous bar. That
|
||||
// is a real trade, taken on purpose — but the guard stays, because the
|
||||
// failure mode here is a strip that keeps growing a line at a time until
|
||||
// it crowds out the route it is supposed to be navigating.
|
||||
final h = tester.getSize(find.byType(TripSelector)).height;
|
||||
// The old segmented strip carried a numbered disc, the word "Trip" and a
|
||||
// status line, and came to ~70pt. This carries a numeral and a bar, and
|
||||
// is bigger *per target* at the same overall height — the budget did not
|
||||
// move, the content did.
|
||||
expect(
|
||||
h,
|
||||
lessThan(80),
|
||||
reason:
|
||||
'the tab strip is pinned furniture — it is on screen at '
|
||||
'every scroll position, so its height is charged continuously',
|
||||
'the selector is header furniture — it is on screen at every '
|
||||
'scroll position, so its height is charged continuously',
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('still show three slots before the hub assigns them', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('names three trips, and says only that', (tester) async {
|
||||
await pump(
|
||||
tester,
|
||||
TripTabs(
|
||||
TripSelector(
|
||||
trips: [
|
||||
Trip.fromStops(id: 't1', stops: [stop('A')]),
|
||||
],
|
||||
@@ -269,9 +273,13 @@ void main() {
|
||||
onSelect: (_) {},
|
||||
),
|
||||
);
|
||||
expect(find.text('Trip 1'), findsOneWidget);
|
||||
expect(find.text('Trip 2'), findsOneWidget);
|
||||
expect(find.text('Trip 3'), findsOneWidget);
|
||||
for (final n in ['Trip 1', 'Trip 2', 'Trip 3']) {
|
||||
expect(find.text(n), findsOneWidget);
|
||||
}
|
||||
// The run's count belongs to the card below, in type four times the
|
||||
// size. A filter says what it filters to. See [TripSelector].
|
||||
expect(find.text('0 / 1'), findsNothing);
|
||||
expect(find.text('Not set'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -599,7 +607,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('only on START · HUB, never on RETURN · HUB', (tester) async {
|
||||
testWidgets('only on START · BASE, never on RETURN · BASE', (tester) async {
|
||||
await pump(
|
||||
tester,
|
||||
TripCard(
|
||||
@@ -614,16 +622,26 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
tester.getRect(find.text('Map')).top,
|
||||
lessThan(tester.getRect(find.text('RETURN · HUB')).top),
|
||||
lessThan(tester.getRect(find.text('RETURN · BASE')).top),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Trip tab states', () {
|
||||
// ── The per-segment colour test is gone with the word it probed ──
|
||||
//
|
||||
// It found each segment by the shared text "Trip" and read that Text's
|
||||
// colour. There is no shared word any more — a segment is a numeral and a
|
||||
// bar — and what the test was really guarding, that the selected trip reads
|
||||
// as picked, is now carried by the travelling marker rather than by ink on a
|
||||
// noun. `trip_tabs_pinned_test` measures the marker against the numeral it
|
||||
// belongs to, which is the same guarantee stated in the terms the control
|
||||
// now uses.
|
||||
|
||||
group('Trip selector marker', () {
|
||||
Future<void> pumpTabs(WidgetTester tester, {required int selected}) async {
|
||||
await pump(
|
||||
tester,
|
||||
TripTabs(
|
||||
TripSelector(
|
||||
trips: [
|
||||
Trip.fromStops(id: 't1', stops: [stop('A')]),
|
||||
Trip.fromStops(id: 't2', stops: [stop('B')]),
|
||||
@@ -634,58 +652,7 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
Color colourOf(WidgetTester tester, String label) =>
|
||||
tester.widget<Text>(find.text(label)).style!.color!;
|
||||
|
||||
testWidgets('the selected tab takes the brand accent', (tester) async {
|
||||
// The bug: the pill moved but the label stayed slate, so standing on
|
||||
// Trip 2 looked no different from Trip 2 sitting idle.
|
||||
await pumpTabs(tester, selected: 1);
|
||||
expect(
|
||||
colourOf(tester, 'Trip 2'),
|
||||
ColorConstants.primary,
|
||||
reason: 'the tab the rider is on must read as picked',
|
||||
);
|
||||
expect(colourOf(tester, 'Trip 1'), isNot(ColorConstants.primary));
|
||||
|
||||
await pumpTabs(tester, selected: 0);
|
||||
expect(colourOf(tester, 'Trip 1'), ColorConstants.primary);
|
||||
expect(
|
||||
colourOf(tester, 'Trip 3'),
|
||||
isNot(ColorConstants.primary),
|
||||
reason: 'an unassigned slot stays quiet',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the strip stays compact after the state work', (tester) async {
|
||||
await pumpTabs(tester, selected: 0);
|
||||
final h = tester.getSize(find.byType(TripTabs)).height;
|
||||
expect(
|
||||
h,
|
||||
lessThan(66),
|
||||
reason:
|
||||
'measured $h — the segmented strip is pinned furniture, so '
|
||||
'every point of it is charged at every scroll position',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Trip tab selection marker', () {
|
||||
Future<void> pumpTabs(WidgetTester tester, {required int selected}) async {
|
||||
await pump(
|
||||
tester,
|
||||
TripTabs(
|
||||
trips: [
|
||||
Trip.fromStops(id: 't1', stops: [stop('A')]),
|
||||
Trip.fromStops(id: 't2', stops: [stop('B')]),
|
||||
],
|
||||
selectedIndex: selected,
|
||||
onSelect: (_) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Same tree, but leaves the pill mid-flight so it can be sampled.
|
||||
/// Same tree, but left mid-flight so the marker can be sampled.
|
||||
Future<void> pumpTabsNoSettle(
|
||||
WidgetTester tester, {
|
||||
required int selected,
|
||||
@@ -696,7 +663,7 @@ void main() {
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: TripTabs(
|
||||
child: TripSelector(
|
||||
trips: [
|
||||
Trip.fromStops(id: 't1', stops: [stop('A')]),
|
||||
Trip.fromStops(id: 't2', stops: [stop('B')]),
|
||||
@@ -714,128 +681,101 @@ void main() {
|
||||
|
||||
/// The travelling selection marker.
|
||||
///
|
||||
/// Found by key, not by colour. It moved out of the segments and into the
|
||||
/// pill when it was made to travel, and colour cannot identify it any more:
|
||||
/// crossing an unassigned trip it is deliberately grey rather than the brand
|
||||
/// red, so a colour finder loses it exactly during the journey these tests
|
||||
/// exist to measure.
|
||||
Rect underline(WidgetTester tester) {
|
||||
final marks = find.byKey(TripTabs.markerKey);
|
||||
/// Found by key, not by colour. It moved out of the segments and into a
|
||||
/// single travelling object when it was made to travel, and colour cannot
|
||||
/// identify it: the segments themselves fade between two inks as it passes,
|
||||
/// so a colour finder loses it exactly during the journey these tests exist
|
||||
/// to measure.
|
||||
Rect marker(WidgetTester tester) {
|
||||
final marks = find.byKey(TripSelector.markerKey);
|
||||
expect(
|
||||
marks,
|
||||
findsOneWidget,
|
||||
reason: 'exactly one tab may be marked at a time',
|
||||
reason: 'exactly one trip may be marked at a time',
|
||||
);
|
||||
return tester.getRect(marks);
|
||||
}
|
||||
|
||||
/// One tab's label.
|
||||
Rect numeral(WidgetTester tester, int slot) =>
|
||||
tester.getRect(find.text('Trip ${slot + 1}'));
|
||||
|
||||
// The marker followed a separate "this trip is running" signal for a while,
|
||||
// which left Trip 1 permanently underlined while the rider stood on Trip 2 —
|
||||
// so a tap appeared to do nothing. The marker belongs to selection.
|
||||
testWidgets('the underline sits under the selected tab', (tester) async {
|
||||
await pumpTabs(tester, selected: 0);
|
||||
final first = underline(tester);
|
||||
final trip1 = tester.getRect(find.text('Trip 1'));
|
||||
expect(
|
||||
first.center.dx,
|
||||
closeTo(trip1.center.dx, 2),
|
||||
reason: 'the marker should be under Trip 1',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('and travels when a different tab is picked', (tester) async {
|
||||
await pumpTabs(tester, selected: 0);
|
||||
final before = underline(tester).center.dx;
|
||||
|
||||
// which left trip 1 permanently marked while the rider stood on trip 2 — so
|
||||
// a tap appeared to do nothing. The marker belongs to selection.
|
||||
testWidgets('the marker sits under the selected pill', (tester) async {
|
||||
await pumpTabs(tester, selected: 1);
|
||||
await tester.pumpAndSettle();
|
||||
final after = underline(tester).center.dx;
|
||||
final trip2 = tester.getRect(find.text('Trip 2'));
|
||||
|
||||
expect(
|
||||
after,
|
||||
greaterThan(before),
|
||||
reason:
|
||||
'the marker moved from $before to $after — it must follow '
|
||||
'the selection rightward to Trip 2',
|
||||
);
|
||||
expect(after, closeTo(trip2.center.dx, 2));
|
||||
final m = marker(tester);
|
||||
final second = numeral(tester, 1);
|
||||
// A short centred rule, not a full-width underline — so it is centred on
|
||||
// its pill rather than spanning it.
|
||||
expect(m.center.dx, closeTo(second.center.dx, 6));
|
||||
expect(m.top, greaterThan(second.bottom));
|
||||
});
|
||||
|
||||
testWidgets('Trip 3 → Trip 1 slides across Trip 2 rather than jumping', (
|
||||
testWidgets('and travels when a different segment is picked', (
|
||||
tester,
|
||||
) async {
|
||||
// The pill used to belong to each segment: one faded out on the right
|
||||
// while a *different* one faded in on the left, so nothing ever occupied
|
||||
// the space between and there was no object to cross Trip 2 with.
|
||||
await pumpTabs(tester, selected: 2);
|
||||
await tester.pumpAndSettle();
|
||||
final trip2 = tester.getRect(find.text('Trip 2'));
|
||||
final start = underline(tester).center.dx;
|
||||
expect(
|
||||
start,
|
||||
greaterThan(trip2.center.dx),
|
||||
reason: 'sanity: the marker starts to the right of Trip 2',
|
||||
);
|
||||
await pumpTabs(tester, selected: 0);
|
||||
final first = marker(tester).center.dx;
|
||||
|
||||
await pumpTabs(tester, selected: 2);
|
||||
final third = marker(tester).center.dx;
|
||||
|
||||
expect(
|
||||
third,
|
||||
greaterThan(first),
|
||||
reason: 'the marker did not move with the selection',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('3 → 1 slides across 2 rather than jumping', (tester) async {
|
||||
// The regression this whole mechanism exists for: the marker used to be
|
||||
// each segment's own fill, so crossing two slots faded one out on the
|
||||
// right and a different one in on the left. Nothing ever occupied the
|
||||
// space between.
|
||||
await pumpTabs(tester, selected: 2);
|
||||
final middle = numeral(tester, 1);
|
||||
|
||||
// `pumpTabs` pumps and settles; drive the frames by hand instead so the
|
||||
// journey can be sampled.
|
||||
await pumpTabsNoSettle(tester, selected: 0);
|
||||
|
||||
final samples = <double>[];
|
||||
for (var elapsed = 0; elapsed < 420; elapsed += 20) {
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 20));
|
||||
samples.add(underline(tester).center.dx);
|
||||
samples.add(marker(tester).center.dx);
|
||||
}
|
||||
|
||||
final half = trip2.width / 2;
|
||||
final half = middle.width;
|
||||
expect(
|
||||
samples.any((x) => (x - trip2.center.dx).abs() < half),
|
||||
samples.any((x) => (x - middle.center.dx).abs() < half * 3),
|
||||
isTrue,
|
||||
reason: 'the marker never passed over Trip 2; samples: $samples',
|
||||
reason: 'the marker never passed over tab 2; samples: \$samples',
|
||||
);
|
||||
for (var i = 1; i < samples.length; i++) {
|
||||
expect(
|
||||
samples[i],
|
||||
lessThanOrEqualTo(samples[i - 1] + 0.5),
|
||||
reason: 'the marker moved backwards at sample $i: $samples',
|
||||
reason: 'the marker moved backwards at sample \$i: \$samples',
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
underline(tester).center.dx,
|
||||
closeTo(tester.getRect(find.text('Trip 1')).center.dx, 2),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the pill is the brand tint, not a white card', (tester) async {
|
||||
// It was a white pill with a drop shadow, which made this the one
|
||||
// selected-state in the app that was not the brand colour — the two
|
||||
// controls a rider uses most looked like they came from different apps.
|
||||
await pumpTabs(tester, selected: 0);
|
||||
|
||||
final fills = tester
|
||||
.widgetList<Container>(find.byType(Container))
|
||||
.map((c) => c.decoration)
|
||||
.whereType<BoxDecoration>()
|
||||
.toList();
|
||||
|
||||
final glass = ColorConstants.primary.withValues(alpha: 0.12);
|
||||
expect(
|
||||
fills.map((d) => d.color),
|
||||
contains(glass),
|
||||
reason:
|
||||
'the selection pill should be the light glass red the bottom '
|
||||
'nav bar uses',
|
||||
);
|
||||
expect(
|
||||
fills.where(
|
||||
(d) => d.color == Colors.white && (d.boxShadow?.isNotEmpty ?? false),
|
||||
),
|
||||
isEmpty,
|
||||
reason: 'no raised white pill should remain',
|
||||
);
|
||||
testWidgets('exactly one marker, whatever is selected', (tester) async {
|
||||
// ── This assertion has been on both sides, and the principle held ──
|
||||
//
|
||||
// It began as "no raised white pill" — the bar was bare words with a
|
||||
// travelling tint behind them, and a white card would have been the
|
||||
// loudest object on a page whose brief was that containers recede. Then
|
||||
// the bar became a segmented control on the canvas, and the pill was the
|
||||
// design rather than the bug. It is now a white marker on the brand
|
||||
// header, where white is simply the only mark that reads.
|
||||
//
|
||||
// What has to hold through all three is the part that always did: **one**
|
||||
// mark of selection, never three.
|
||||
for (final selected in const [0, 1, 2]) {
|
||||
await pumpTabs(tester, selected: selected);
|
||||
expect(find.byKey(TripSelector.markerKey), findsOneWidget);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -936,10 +876,23 @@ void main() {
|
||||
// chip, and there is no bound on how many of those a shift produces. Six
|
||||
// stops in, the rider was scrolling past six rows he could do nothing with to
|
||||
// reach the one he could. They move to the Activity tab — but the *trip*
|
||||
// keeps them, because the brief's remaining count and its parcel figure are
|
||||
// counted from it.
|
||||
// object keeps them, because the numbering and the progress head are
|
||||
// measured against a total that must not move.
|
||||
//
|
||||
// ── What the brief's four figures do NOT do ──
|
||||
//
|
||||
// They used to be counted from that same total, so duration, distance and
|
||||
// the parcel count described the slot as assigned and never moved while the
|
||||
// rider worked it. `20 stops left` counted down above four figures that
|
||||
// still described the whole morning, including everything already collected
|
||||
// and handed to Deliveries.
|
||||
//
|
||||
// They are counted from [Trip.outstanding] now — the collections still owed
|
||||
// — so the two halves of the card finally answer the same question. The
|
||||
// trip's own totals are untouched, which is what the first two assertions
|
||||
// below are for.
|
||||
group('completed stops', () {
|
||||
testWidgets('are not listed, but still count towards the trip', (
|
||||
testWidgets('leave the list and the figures, but not the trip', (
|
||||
tester,
|
||||
) async {
|
||||
final trip = Trip.fromStops(
|
||||
@@ -971,34 +924,44 @@ void main() {
|
||||
// a trip of one with none done both say. The parcel count can only reach
|
||||
// 2 if the finished stop is still part of the trip, which is the whole
|
||||
// contract here.
|
||||
// The hero sets the figure and its caption as two texts — numeral at
|
||||
// headline scale, words as its caption — so the claim is asserted on
|
||||
// the pair rather than on one sentence.
|
||||
// The headline is one string now — `1 stop left` — rather than a numeral
|
||||
// at headline scale beside its caption at body scale. Two sizes and a
|
||||
// manual baseline nudge to make a figure and its unit look like one
|
||||
// thing, when they are one thing.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TripBriefStrip),
|
||||
matching: find.text('1'),
|
||||
matching: find.text('1 stop left'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('stop left'), findsOneWidget);
|
||||
final singleStop = Trip.fromStops(id: 't2', stops: [stop('B')]);
|
||||
expect(
|
||||
trip.totalParcels,
|
||||
greaterThan(singleStop.totalParcels),
|
||||
reason: 'the collected stop still contributes its load',
|
||||
reason: 'the TRIP still carries the collected stop and its load',
|
||||
);
|
||||
// The parcel figure is reference now, so it is behind the fold — but it
|
||||
// must still be the *trip's* total rather than the visible one, which is
|
||||
// the contract this test exists for.
|
||||
|
||||
// ── But the brief states what is left, not what the slot held ──
|
||||
//
|
||||
// Stop A is `picked`, so it is across the boundary and belongs to
|
||||
// Deliveries. One collection is still owed, and the parcel figure says
|
||||
// one — the trip's own total of two is deliberately NOT what is drawn.
|
||||
final outstanding = trip.outstanding();
|
||||
expect(
|
||||
outstanding.totalParcels,
|
||||
lessThan(trip.totalParcels),
|
||||
reason: 'a collected stop is no longer work in front of the rider',
|
||||
);
|
||||
|
||||
await openBrief(tester);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TripBriefStrip),
|
||||
matching: find.text('${trip.totalParcels}'),
|
||||
matching: find.text('${outstanding.totalParcels}'),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason: 'and the card states that total, not the visible one',
|
||||
reason: 'the card states the outstanding count, not the slot total',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1041,12 +1004,16 @@ void main() {
|
||||
//
|
||||
// The brief and the route now stand on [MilerPanel], layer 1 of the
|
||||
// surface ladder, because Home's ground is the canvas and nothing white
|
||||
// was standing on it. The panel owns the inset: `panelGutter` 12 outside
|
||||
// the white plus `panelPad` 14 inside it, and `TripCard` no longer adds a
|
||||
// gutter of its own. So both land on 26, and — the part that matters —
|
||||
// they land on the *same* number. `home_gutter_test` holds them together.
|
||||
const homeGutter = 26.0;
|
||||
const stopRowInset = 26.0;
|
||||
// was standing on it. The panel owns the inset, and `TripCard` no longer
|
||||
// adds a gutter of its own — so both land on the *same* number, which is
|
||||
// the part that matters. `home_gutter_test` holds them together.
|
||||
//
|
||||
// 26 → 29: `panelGutter` 12 outside the white, `panelPad` 16 inside it,
|
||||
// and one point for the hairline the card carries now (see [Narrative] —
|
||||
// the border is half of what buys a card its separation on the lighter
|
||||
// ground). A pixel of border is a pixel of inset.
|
||||
const homeGutter = 29.0;
|
||||
const stopRowInset = 29.0;
|
||||
|
||||
testWidgets('the summary cards and the stop rows each keep their inset', (
|
||||
tester,
|
||||
@@ -1401,15 +1368,23 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
// ── The disclosure has to LOOK like one ──
|
||||
// ── The header's right edge belongs to the journey ──
|
||||
//
|
||||
// The chevron was removed once when the header became the toggle, and its
|
||||
// signal went with it: a collapsed kitchen showed nothing that said "there
|
||||
// is a list under me". It is back as a passive indicator inside the
|
||||
// header's own gesture — one arena, one owner — and this holds the three
|
||||
// facts that make it honest: it exists, it turns with the state, and a
|
||||
// flat stop (whose tap navigates instead) wears the pointing variant.
|
||||
testWidgets('the header wears a chevron that turns with the state', (
|
||||
// A chevron has been on and off this header three times, and the argument
|
||||
// each way was about what the right edge was *for*. It came back once as a
|
||||
// passive indicator, because a collapsed kitchen showed nothing that said
|
||||
// "there is a list under me".
|
||||
//
|
||||
// The corner is not empty any more: the distance and the ride time live
|
||||
// there, and they are the reason a rider looks at it — down a multi-kitchen
|
||||
// run they form the column that answers *which of these is nearest*. A
|
||||
// glyph between them and the card's edge pushed that column ~24 points off
|
||||
// the margin so it lined up with nothing.
|
||||
//
|
||||
// What is asserted now is the trade: no fold mark on a group header, and
|
||||
// the journey column sitting on the card's own right edge. The fold state
|
||||
// is still legible — an open group has its orders on screen under it.
|
||||
testWidgets('the group header ends on its journey, not a chevron', (
|
||||
tester,
|
||||
) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
@@ -1426,43 +1401,45 @@ void main() {
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'Quantity': 1,
|
||||
'orderstatus': 'new',
|
||||
'pickuplat': 11.04,
|
||||
'pickuplon': 76.98,
|
||||
},
|
||||
];
|
||||
await pump(
|
||||
tester,
|
||||
TripCard(
|
||||
trip: Trip.fromStops(id: 't', stops: stops),
|
||||
riderLat: 11.0168,
|
||||
riderLng: 76.9558,
|
||||
onToggleSelect: (_) {},
|
||||
onStopTap: (_) {},
|
||||
),
|
||||
);
|
||||
|
||||
// Scoped to the route. The trip brief above it also folds, and since
|
||||
// Home moved to one icon family both disclosure marks are the same
|
||||
// glyph — correctly, they mean the same thing — so a bare `byIcon` here
|
||||
// matches the brief's chevron as well and stops being a statement about
|
||||
// the group header at all.
|
||||
final chevron = find.descendant(
|
||||
of: find.byType(RouteTimeline),
|
||||
matching: find.byIcon(LucideIcons.chevronDown),
|
||||
// Scoped to the route: the trip brief above it still folds and wears the
|
||||
// same glyph, so an unscoped finder would be a claim about the whole card.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(RouteTimeline),
|
||||
matching: find.byIcon(LucideIcons.chevronDown),
|
||||
),
|
||||
findsNothing,
|
||||
reason: 'the corner is the journey column, not a fold mark',
|
||||
);
|
||||
expect(chevron, findsOneWidget);
|
||||
|
||||
double turns() => tester
|
||||
.widget<AnimatedRotation>(
|
||||
find.ancestor(of: chevron, matching: find.byType(AnimatedRotation)),
|
||||
)
|
||||
.turns;
|
||||
|
||||
final openTurns = turns();
|
||||
// And the group still folds — the gesture was always the header's, and
|
||||
// removing the glyph must not have taken the behaviour with it.
|
||||
// Measured by height rather than by the rows' presence: the body
|
||||
// collapses through an `AnimatedSize`, which keeps its child mounted at
|
||||
// zero height, so a `findsNothing` here would be asserting an
|
||||
// implementation detail of the animation rather than the fold.
|
||||
final open = tester.getSize(find.byType(RouteTimeline)).height;
|
||||
await tester.tap(find.text('Vidhya Kitchen'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
turns(),
|
||||
isNot(openTurns),
|
||||
reason:
|
||||
'the chevron must turn when the list opens or closes — a '
|
||||
'static disclosure mark is a printed one',
|
||||
tester.getSize(find.byType(RouteTimeline)).height,
|
||||
lessThan(open),
|
||||
reason: 'tapping the header still closes its orders',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -106,8 +106,9 @@ void main() {
|
||||
test('a genuine hub hold stays blocked — the logistics guard', () {
|
||||
// The guard the brief requires be kept: a consignment inside the hub's
|
||||
// half of the network is not this rider's to hand over.
|
||||
//
|
||||
// `Created` is deliberately NOT in this list — see the test below.
|
||||
for (final s in const [
|
||||
ConsignmentState.created,
|
||||
ConsignmentState.inwardedAtHub,
|
||||
ConsignmentState.tripsheetLoaded,
|
||||
ConsignmentState.inTransit,
|
||||
@@ -118,6 +119,36 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('`Created` is the rider\'s own parcel, not a hub hold', () {
|
||||
// The bug this pins: `Created` was in the hub list, so sliding **Start
|
||||
// ride** on a freshly converted consignment answered "This parcel is
|
||||
// with the hub — it will be delivered from there, not by you" about a
|
||||
// bag on the rider's own back, and nothing on that screen cleared it.
|
||||
//
|
||||
// The backend writes `Created` at the pivot for hub-routed work
|
||||
// (`next_action: inward_at_hub`) and holds it there transiently while
|
||||
// hyperlocal work is routed. The hub has not taken it in either way.
|
||||
const s = ConsignmentState.created;
|
||||
expect(s.awaitsHub, isFalse, reason: 'the hub has never seen it');
|
||||
expect(s.awaitsHubInward, isTrue);
|
||||
expect(ConsignmentGate.gateFor(s), DeliverGate.awaitingInward);
|
||||
|
||||
// Still not deliverable: `deliver` only accepts `Out_for_Delivery`. The
|
||||
// fix changes which sentence the rider is shown, never what is claimed.
|
||||
expect(s.isDeliverable, isFalse);
|
||||
expect(s.needsRelease, isFalse);
|
||||
|
||||
// And no other state may drift into the new rung.
|
||||
for (final other in ConsignmentState.values) {
|
||||
if (other == ConsignmentState.created) continue;
|
||||
expect(
|
||||
other.awaitsHubInward,
|
||||
isFalse,
|
||||
reason: '${other.name} is not the pre-inward rung',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('closed work is refused without blaming the rider', () {
|
||||
for (final s in const [
|
||||
ConsignmentState.cancelled,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:miler/data/order_events.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/activity/delivery_details_page.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// DELIVERY DETAILS — one stop, the whole story
|
||||
@@ -111,6 +112,20 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the full journey log.
|
||||
///
|
||||
/// The rungs are behind **View full log** now: the panel leads with a
|
||||
/// one-line strip of the same ladder, and the detailed version — clocks,
|
||||
/// places, addresses, the gaps between them — is one tap under it. Every
|
||||
/// assertion about *which* rungs exist and in what order is unchanged; what
|
||||
/// changed is that reading them costs a tap, so the tests take it.
|
||||
Future<void> openLog(WidgetTester tester) async {
|
||||
final link = find.text('View full log');
|
||||
if (link.evaluate().isEmpty) return;
|
||||
await tester.tap(link);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
group('the summary', () {
|
||||
@@ -124,7 +139,11 @@ void main() {
|
||||
// stop cannot be called one thing on the list and another on the page
|
||||
// that list opens. It appears twice: the summary and the timeline's head
|
||||
// rung, which is the newest event.
|
||||
expect(find.text('Delivered'), findsNWidgets(2));
|
||||
// Once, not twice. The timeline's own copy of the word is behind
|
||||
// **View full log** now — the strip above it carries `DELIVERED`, and
|
||||
// the card under it says where — so the only `Delivered` on a closed
|
||||
// record page is the verdict itself.
|
||||
expect(find.text('Delivered'), findsOneWidget);
|
||||
final headline = tester.widget<Text>(find.text('Delivered').first);
|
||||
expect(headline.style!.color, ColorConstants.acceptGreen);
|
||||
|
||||
@@ -175,12 +194,15 @@ void main() {
|
||||
testWidgets('runs oldest first, named for the leg', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
await pump(tester, record());
|
||||
await openLog(tester);
|
||||
|
||||
// The door arrival is not a rung: it folds into the outcome as a
|
||||
// duration, because "he got there 5m before he handed it over" is a
|
||||
// subtraction, not an event worth its own line.
|
||||
expect(find.text('Arrived at customer'), findsNothing);
|
||||
expect(find.text('5m at the door'), findsOneWidget);
|
||||
// Twice with the log open: on the rung, and on the card under the strip
|
||||
// that restates how the last leg went.
|
||||
expect(find.text('5m at the door'), findsNWidgets(2));
|
||||
expect(find.text('Out for delivery'), findsOneWidget);
|
||||
|
||||
// ── Downwards, like every order-tracking screen ──
|
||||
@@ -199,6 +221,7 @@ void main() {
|
||||
|
||||
testWidgets('a collection is named for its own leg', (tester) async {
|
||||
await pump(tester, record(type: 'pickup', status: 'picked'));
|
||||
await openLog(tester);
|
||||
|
||||
expect(find.text('Picked up'), findsWidgets);
|
||||
// On a collection `arrivedat` IS the counter, so it fills the same rung
|
||||
@@ -224,6 +247,7 @@ void main() {
|
||||
arrived: false,
|
||||
),
|
||||
);
|
||||
await openLog(tester);
|
||||
|
||||
expect(find.text('Set off'), findsNothing);
|
||||
expect(find.text('Arrived'), findsNothing);
|
||||
@@ -250,6 +274,7 @@ void main() {
|
||||
tester,
|
||||
record(type: 'pickup', status: 'picked', arrived: false),
|
||||
);
|
||||
await openLog(tester);
|
||||
expect(find.text('Set off'), findsOneWidget);
|
||||
expect(find.text('Arrived'), findsNothing);
|
||||
});
|
||||
@@ -280,6 +305,7 @@ void main() {
|
||||
.add(const Duration(minutes: 65))
|
||||
.toIso8601String(),
|
||||
});
|
||||
await openLog(tester);
|
||||
|
||||
for (final rung in const [
|
||||
'Assigned',
|
||||
@@ -335,13 +361,15 @@ void main() {
|
||||
// existed. It must degrade to what it has, not to a page of placeholders.
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
await pump(tester, record());
|
||||
await openLog(tester);
|
||||
|
||||
expect(find.text('Delivered'), findsNWidgets(2));
|
||||
// The door arrival is not a rung: it folds into the outcome as a
|
||||
// duration, because "he got there 5m before he handed it over" is a
|
||||
// subtraction, not an event worth its own line.
|
||||
expect(find.text('Arrived at customer'), findsNothing);
|
||||
expect(find.text('5m at the door'), findsOneWidget);
|
||||
// The rung, and the card under the strip.
|
||||
expect(find.text('5m at the door'), findsNWidgets(2));
|
||||
expect(find.text('Out for delivery'), findsOneWidget);
|
||||
for (final absent in const [
|
||||
'Order placed',
|
||||
@@ -363,18 +391,22 @@ void main() {
|
||||
tester,
|
||||
record(status: 'skipped', skipReason: 'Customer unreachable'),
|
||||
);
|
||||
await openLog(tester);
|
||||
|
||||
// The one rung on this page allowed to exist without a timestamp: a
|
||||
// skipped stop has a delivery in its story that did not occur, and saying
|
||||
// so is the point of the record. It is an open ring, not a filled disc,
|
||||
// so it cannot read as a step that is merely pending.
|
||||
expect(find.text('Delivered'), findsOneWidget);
|
||||
expect(find.text('Not completed — return visit owed'), findsOneWidget);
|
||||
// The unreached rung, and the card under the strip that names where the
|
||||
// story stopped.
|
||||
expect(find.text('Delivered'), findsNWidgets(2));
|
||||
expect(find.text('Not completed — return visit owed'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('a completed stop has nothing left unreached', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
await pump(tester, record());
|
||||
await openLog(tester);
|
||||
|
||||
// Twice — the summary and the timeline's last rung — and never a third
|
||||
// time as a step still to come.
|
||||
@@ -388,12 +420,13 @@ void main() {
|
||||
testWidgets('a round draws both ends; a parcel draws one', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
await pump(tester, record());
|
||||
await openLog(tester);
|
||||
// ── ROUTE is gone; its two addresses moved onto the journey ──
|
||||
//
|
||||
// Tracking said *when*, Route said *where*, and the rider joined them
|
||||
// himself. Each place is now written onto the rung that happened there.
|
||||
expect(find.text('ROUTE'), findsNothing);
|
||||
expect(find.text('JOURNEY'), findsOneWidget);
|
||||
expect(find.text('Journey'), findsOneWidget);
|
||||
|
||||
// Once now, not twice. The hero's quiet `from → to` line names both
|
||||
// ends; the journey names the place on the rung that happened there,
|
||||
@@ -402,11 +435,14 @@ void main() {
|
||||
expect(find.text('Vidhya Kitchen'), findsOneWidget);
|
||||
expect(
|
||||
find.text('78, Cross Cut Road, Gandhipuram'),
|
||||
findsOneWidget,
|
||||
findsNWidgets(2),
|
||||
reason:
|
||||
'the address hangs on the rung that happened at it. With no '
|
||||
'drop address on the booking the one address it has belongs to '
|
||||
'the door — the fallback ROUTE encoded and this inherited.',
|
||||
'the door — the fallback ROUTE encoded and this inherited. '
|
||||
'Twice with the log open: the rung, and the card under the strip '
|
||||
'that restates where it ended, which is the one line the strip '
|
||||
'itself has no room for.',
|
||||
);
|
||||
|
||||
// A parcel booking is a first-mile collection *from* the customer, and
|
||||
@@ -415,7 +451,7 @@ void main() {
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
await pump(tester, record(type: 'pickup', status: 'picked'));
|
||||
expect(find.text('ROUTE'), findsNothing);
|
||||
expect(find.text('JOURNEY'), findsOneWidget);
|
||||
expect(find.text('Journey'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -431,23 +467,31 @@ void main() {
|
||||
);
|
||||
await unfold(tester);
|
||||
|
||||
// ── The labelled stats strip is gone ──
|
||||
// ── The stats strip is back, as the app's own fact strip ──
|
||||
//
|
||||
// DISTANCE / TOTAL TIME / ORDERS was three captioned cells in a tinted
|
||||
// box — a dashboard widget, and a dashboard is for comparing many things
|
||||
// across records. There is one stop on this page and it is finished. The
|
||||
// figures still earn a place; they earn a line.
|
||||
expect(find.text('DISTANCE'), findsNothing);
|
||||
expect(find.text('TOTAL TIME'), findsNothing);
|
||||
// It was removed once, as DISTANCE / TOTAL TIME / ORDERS in a tinted box,
|
||||
// on the argument that a dashboard is for comparing many things and there
|
||||
// is one stop on this page. That argument was about the *panel*; what
|
||||
// replaced it — one grey centred sentence — went too far the other way,
|
||||
// and the page it left behind read as unfinished.
|
||||
//
|
||||
// What is here now is the tonal fact strip Account and the stop sheet
|
||||
// already use, inside the panel that owns the figures. So the assertion
|
||||
// flips: the cells exist, and what is still forbidden is a cell for
|
||||
// something nobody measured (below).
|
||||
expect(find.text('DISTANCE'), findsOneWidget);
|
||||
|
||||
// The distance now reads once in that line, and once in the folded row
|
||||
// that names it.
|
||||
expect(find.text('4.6 km'), findsOneWidget);
|
||||
// Twice, and both are honest: the strip's value, and the folded row that
|
||||
// names it against the planned figure.
|
||||
expect(find.text('4.6 km'), findsNWidgets(2));
|
||||
expect(find.textContaining('4.6 km'), findsWidgets);
|
||||
expect(find.text('4.1 km'), findsOneWidget);
|
||||
|
||||
// The brief's emerald, which is what "delivered / met / yes" is set in
|
||||
// on the rebuilt screens. Same meaning, one hue deeper than the old
|
||||
// `acceptGreen`.
|
||||
final verdict = tester.widget<Text>(find.text('Yes'));
|
||||
expect(verdict.style!.color, ColorConstants.acceptGreen);
|
||||
expect(verdict.style!.color, Narrative.emerald);
|
||||
|
||||
// A record that invents a plausible number cannot be caught.
|
||||
for (final invented in const ['0 km', '0.0 km', '0m', '₹0']) {
|
||||
@@ -474,7 +518,9 @@ void main() {
|
||||
testWidgets('the reference is there and copyable', (tester) async {
|
||||
await pump(tester, record(type: 'pickup', status: 'picked'));
|
||||
await unfold(tester);
|
||||
expect(find.text('#DM101'), findsOneWidget);
|
||||
// Twice: the app bar's quiet identifier and the folded row that owns
|
||||
// it. Only one of them is copyable, which is the point of the row.
|
||||
expect(find.text('#DM101'), findsNWidgets(2));
|
||||
expect(find.byIcon(LucideIcons.copy), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -518,9 +564,10 @@ void main() {
|
||||
await unfold(tester);
|
||||
|
||||
expect(find.text('Payment'), findsWidgets);
|
||||
// Due, collected, and again under EARNINGS — the cash that changed hands
|
||||
// is the one earnings figure this stop really has.
|
||||
expect(find.text('₹340'), findsNWidgets(3));
|
||||
// Four: the fact strip at the top of the page, then due, collected, and
|
||||
// again under EARNINGS — the cash that changed hands is the one earnings
|
||||
// figure this stop really has.
|
||||
expect(find.text('₹340'), findsNWidgets(4));
|
||||
// Earnings folded into Payment — one group about money, not two.
|
||||
expect(find.text('Payment'), findsWidgets);
|
||||
expect(find.text('Cash collected'), findsOneWidget);
|
||||
@@ -576,11 +623,14 @@ void main() {
|
||||
// Both addresses now sit on the journey, each on the rung that happened
|
||||
// at it — the counter on the collection, the door on the outcome — so
|
||||
// this reads the ladder rather than a separate ROUTE section.
|
||||
await openLog(tester);
|
||||
final kitchen = tester.getRect(
|
||||
find.text('4 Cross, RS Puram, Coimbatore 641002'),
|
||||
);
|
||||
// `.first`: the door's address is on its rung and again on the card
|
||||
// under the strip, and the rung is the one this ordering is about.
|
||||
final door = tester.getRect(
|
||||
find.text('12, SNS Colony, Peelamedu, Coimbatore 641004'),
|
||||
find.text('12, SNS Colony, Peelamedu, Coimbatore 641004').first,
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -600,16 +650,22 @@ void main() {
|
||||
// hanging its one address exactly where it always hung.
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
await pump(tester, record());
|
||||
await openLog(tester);
|
||||
await unfold(tester);
|
||||
|
||||
expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget);
|
||||
// The rung, and the outcome card that restates the last one.
|
||||
expect(find.text('78, Cross Cut Road, Gandhipuram'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('the same street is never printed twice', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
await pump(tester, record());
|
||||
await openLog(tester);
|
||||
await unfold(tester);
|
||||
expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget);
|
||||
// Once on the rung and once on the outcome card, and never a third time
|
||||
// — the duplication this test was written against was a whole ROUTE
|
||||
// section repeating the journey.
|
||||
expect(find.text('78, Cross Cut Road, Gandhipuram'), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,10 +169,13 @@ void main() {
|
||||
// the build flag still reaches it, and a profile that lies about the work
|
||||
// would be worse than one nobody uses.
|
||||
test('names the work Doormile, never the app it was ported from', () {
|
||||
// Exact equality, not a list of things it must not contain. The old
|
||||
// version spelled out the retired platform's name to forbid it, which
|
||||
// put the word back in the repo it was being banned from — and it was
|
||||
// the weaker assertion anyway: `== 'Doormile'` rules out every other
|
||||
// name at once, including ones nobody has thought of.
|
||||
const p = ServiceProfile.delivery;
|
||||
expect(p.label, 'Doormile');
|
||||
expect(p.label.toLowerCase(), isNot(contains('nearle')));
|
||||
expect(p.label.toLowerCase(), isNot(contains('xpress')));
|
||||
});
|
||||
|
||||
test('speaks in deliveries, not bookings', () {
|
||||
|
||||
@@ -7,19 +7,22 @@ import 'package:miler/controllers/pickups_controller.dart';
|
||||
///
|
||||
/// [kGeofenceEnforced] decides whether "Picked up" means the rider was standing
|
||||
/// at the door or merely pressed a button, and it has already been wrong in
|
||||
/// both directions: it was `kDebugMode` — so every debug build had no fence and
|
||||
/// the fence was therefore never tested — and then a hard `false`, so the flow
|
||||
/// could not be walked at a desk at all.
|
||||
/// three directions: it was `kDebugMode` — so every debug build had no fence
|
||||
/// and the fence was therefore never tested — then a hard `false`, so the flow
|
||||
/// could not be walked at a desk at all, then a define defaulting to off.
|
||||
///
|
||||
/// **It is currently OFF**, decided on 2026-08-19 because the fence was
|
||||
/// refusing real work: riders pressing *Picked up* at a counter were told they
|
||||
/// were kilometres away, most often because the booking's own coordinates were
|
||||
/// wrong rather than the rider. See the declaration for the full reasoning and
|
||||
/// for how to restore it.
|
||||
/// **It is OFF, decided 2026-08-25.** It was turned on that morning with a
|
||||
/// radius of [kGeofenceRadiusMeters] and turned off again the same day, at the
|
||||
/// founder's call. Nothing was found wrong with it — this is a decision about
|
||||
/// *when* to switch it on.
|
||||
///
|
||||
/// So this file no longer guards a direction. It guards the two properties that
|
||||
/// made the old defaults dangerous: that the state is *deliberate*, and that
|
||||
/// every gate reads the same switch.
|
||||
/// So the direction this file guards has flipped twice in a day, which is
|
||||
/// exactly the pattern that made the old defaults dangerous, and it is why the
|
||||
/// three tests below are not all about the direction. The measurement work is
|
||||
/// unchanged and still asserted: a `best` fix with a shelf life, the phone's
|
||||
/// error credited to the rider, and one radius rather than three. All of it is
|
||||
/// dormant while [kGeofenceEnforced] is false and none of it needs revisiting
|
||||
/// on the day it goes back on.
|
||||
void main() {
|
||||
test('there is one switch, and the legacy name derives from it', () {
|
||||
// Two constants that could disagree is exactly how a build ends up with
|
||||
@@ -33,8 +36,8 @@ void main() {
|
||||
// If this fails, one of two things happened, and both should be read rather
|
||||
// than silenced:
|
||||
//
|
||||
// • the default was changed back to enforced — which is the intended
|
||||
// end state, so update this test with the date and the reason; or
|
||||
// • the default was turned back on — record the date and the reason here,
|
||||
// as the previous flips did; or
|
||||
// • this run passed `--dart-define=ENFORCE_GEOFENCE=true`, in which case
|
||||
// the failure is correct and is telling you this build has the fence on.
|
||||
expect(
|
||||
@@ -46,4 +49,19 @@ void main() {
|
||||
'--dart-define=ENFORCE_GEOFENCE=true.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the radius is one number, and it is the tight one', () {
|
||||
// Asserted while the fence is off, deliberately. The app used to hold
|
||||
// three — a configured `pickupradius` defaulting to 100 in the controller,
|
||||
// a hardcoded 500 in Home's bulk gate, and no agreement between them — so
|
||||
// which fence a rider met depended on whether he ticked boxes or slid a
|
||||
// sheet. Whichever way the switch above is set, that must not come back.
|
||||
expect(kGeofenceRadiusMeters, 10);
|
||||
});
|
||||
|
||||
test('a cached fix has a shelf life', () {
|
||||
// On a round, a stale position is reliably the *previous* stop. Anything
|
||||
// much longer than this and the fence starts measuring from the last door.
|
||||
expect(kGeofenceFixMaxAge, const Duration(seconds: 30));
|
||||
});
|
||||
}
|
||||
|
||||
214
test/group_accept_count_test.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/home/route_timeline.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip_card.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// `Accepted 1/10`, NOT `Accepted 1/63`
|
||||
///
|
||||
/// The kitchen card's state chip is a fraction: how many of this counter's
|
||||
/// orders the rider has taken, out of how many there are. The numerator was
|
||||
/// right and the denominator was the whole day.
|
||||
///
|
||||
/// ── What it was counting ──
|
||||
///
|
||||
/// [RouteGroup.allStops] is every order the trip ever bucketed under a source,
|
||||
/// and `groupSize` was its raw length. That deliberately includes orders the
|
||||
/// rider has **collected** — they left the visible list but not the counter,
|
||||
/// and renumbering the place under him is how a rider stops trusting a number.
|
||||
///
|
||||
/// It also included every order already **delivered** and every one he had
|
||||
/// **declined**, which are Activity's business and not this card's. Off a
|
||||
/// kitchen that supplies a rider all day that is most of the bucket: ten orders
|
||||
/// in the dropdown, one accepted, and a chip reading `Accepted 1/63` — a
|
||||
/// fraction whose denominator appears nowhere on the screen.
|
||||
///
|
||||
/// The load is now stated the way the rider would state it: **what is still in
|
||||
/// the dropdown, plus what he has already taken off this counter**.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
tearDown(() {
|
||||
Get.reset();
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
});
|
||||
|
||||
/// One order off Vidhya Kitchen. [status] is what the backend calls it, which
|
||||
/// is what `stopStateOf` reads to place it on the ladder.
|
||||
Map<String, dynamic> order(int id, {required String status}) =>
|
||||
<String, dynamic>{
|
||||
'orderid': 'M-$id',
|
||||
'pickupid': id,
|
||||
'step': id,
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'sourceid': 'K1',
|
||||
'pickupcustomer': 'Customer $id',
|
||||
'pickupaddress': '9 Kitchen Street, Coimbatore',
|
||||
'dropaddress': 'Door $id, Peelamedu, Coimbatore',
|
||||
'orderstatus': status,
|
||||
'type': 'delivery',
|
||||
'pickuplat': 11.01,
|
||||
'pickuplon': 76.95,
|
||||
};
|
||||
|
||||
/// The card's own grouping, run the way the screen runs it.
|
||||
List<RouteGroup> groupsFor(
|
||||
List<Map<String, dynamic>> stops, {
|
||||
Set<String> accepted = const {},
|
||||
Set<String> collected = const {},
|
||||
}) {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
return TripCard(
|
||||
trip: Trip.fromStops(id: 'T1', stops: stops),
|
||||
acceptedIds: accepted,
|
||||
collectedIds: collected,
|
||||
).routeGroups();
|
||||
}
|
||||
|
||||
group('the denominator is the counter, not the day', () {
|
||||
test('ten in the dropdown and one accepted reads 1 of 10', () {
|
||||
// The reported case, at the size it was reported: one kitchen, ten
|
||||
// orders still to decide, and fifty-three already delivered today.
|
||||
final stops = <Map<String, dynamic>>[
|
||||
for (var i = 1; i <= 10; i++) order(i, status: 'assigned'),
|
||||
for (var i = 11; i <= 63; i++) order(i, status: 'delivered'),
|
||||
];
|
||||
|
||||
final g = groupsFor(stops, accepted: {'M-1'}).single;
|
||||
|
||||
expect(g.stops.length, 10, reason: 'the dropdown holds ten');
|
||||
expect(g.allStops.length, 63, reason: 'the bucket still holds the day');
|
||||
expect(g.onRungCount, 1);
|
||||
expect(
|
||||
g.groupSize,
|
||||
10,
|
||||
reason: 'fifty-three delivered orders are Activity\'s, not this card\'s',
|
||||
);
|
||||
});
|
||||
|
||||
test('a collected order keeps its place in the arithmetic', () {
|
||||
// The original rationale, which must survive the fix: three bags are in
|
||||
// the rider's box and off Home, but they came off this counter and the
|
||||
// place still holds six.
|
||||
final stops = <Map<String, dynamic>>[
|
||||
for (var i = 1; i <= 6; i++) order(i, status: 'assigned'),
|
||||
];
|
||||
|
||||
final g = groupsFor(
|
||||
stops,
|
||||
accepted: {'M-1', 'M-2', 'M-3'},
|
||||
collected: {'M-1', 'M-2', 'M-3'},
|
||||
).single;
|
||||
|
||||
expect(g.stops.length, 3, reason: 'the collected three left Home');
|
||||
expect(
|
||||
g.groupSize,
|
||||
6,
|
||||
reason: 'renumbering the counter under him is the bug this prevents',
|
||||
);
|
||||
});
|
||||
|
||||
test('a declined order drops out of the load', () {
|
||||
final stops = <Map<String, dynamic>>[
|
||||
for (var i = 1; i <= 4; i++) order(i, status: 'assigned'),
|
||||
order(5, status: 'rejected'),
|
||||
];
|
||||
|
||||
final g = groupsFor(stops).single;
|
||||
|
||||
expect(g.groupSize, 4, reason: 'he has finished with the declined one');
|
||||
});
|
||||
|
||||
test('nothing delivered means the two counts agree', () {
|
||||
final stops = <Map<String, dynamic>>[
|
||||
for (var i = 1; i <= 5; i++) order(i, status: 'assigned'),
|
||||
];
|
||||
|
||||
final g = groupsFor(stops).single;
|
||||
|
||||
expect(g.groupSize, 5);
|
||||
expect(g.groupSize, g.allStops.length);
|
||||
});
|
||||
|
||||
test('a caller that tracks no states gets the old arithmetic', () {
|
||||
// Better to over-count than to invent a denominator: a hand-built group
|
||||
// with no `allStopStates` must not silently report zero.
|
||||
const g = RouteGroup(
|
||||
key: 'K1',
|
||||
name: 'Vidhya Kitchen',
|
||||
stops: [],
|
||||
allStops: [{}, {}, {}],
|
||||
firstIndex: 0,
|
||||
state: StopState.pending,
|
||||
stopStates: [],
|
||||
);
|
||||
|
||||
expect(g.groupSize, 3);
|
||||
});
|
||||
});
|
||||
|
||||
group('the chip on the card says the same number', () {
|
||||
Future<void> pump(
|
||||
WidgetTester tester,
|
||||
List<Map<String, dynamic>> stops, {
|
||||
Set<String> accepted = const {},
|
||||
}) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
tester.view.physicalSize = const Size(390 * 3, 1600 * 3);
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: TripCard(
|
||||
trip: Trip.fromStops(id: 'T1', stops: stops),
|
||||
acceptedIds: accepted,
|
||||
onToggleSelect: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('the accepted chip is a fraction of the dropdown', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester, [
|
||||
for (var i = 1; i <= 10; i++) order(i, status: 'assigned'),
|
||||
for (var i = 11; i <= 63; i++) order(i, status: 'delivered'),
|
||||
], accepted: {'M-1'});
|
||||
|
||||
expect(find.text('Accepted 1/10'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Accepted 1/63'),
|
||||
findsNothing,
|
||||
reason: 'the day is not the counter',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a single-order place states no fraction at all', (
|
||||
tester,
|
||||
) async {
|
||||
// `Accepted (1 of 1)` is noise, and the guard for it reads `groupSize`.
|
||||
await pump(
|
||||
tester,
|
||||
[order(1, status: 'assigned')],
|
||||
accepted: {'M-1'},
|
||||
);
|
||||
|
||||
expect(find.text('Accepted'), findsOneWidget);
|
||||
expect(find.textContaining('Accepted 1/'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
149
test/home_brief_outstanding_test.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE BRIEF DESCRIBES WHAT IS LEFT
|
||||
///
|
||||
/// Home's brief card carries four figures — duration, distance, parcels,
|
||||
/// payment — under a headline that counts down (`20 stops left`).
|
||||
///
|
||||
/// The headline moved as the rider worked. The figures did not. They were
|
||||
/// counted once over every stop the slot ever held, so a rider with five
|
||||
/// collections to go read `≈4h 15m · 29.0 km · 25 parcels`: the morning he
|
||||
/// started with, including the twenty already in his box and gone to
|
||||
/// Deliveries. The one question the card exists to answer — *what is in front
|
||||
/// of me* — was the one thing on it that was stale.
|
||||
///
|
||||
/// [Trip.outstanding] is the recount. The line it draws is
|
||||
/// `WorkBoundary.pickupComplete`, the same boundary Home and Deliveries split
|
||||
/// on, so a stop cannot be counted here and owned there.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
Map<String, dynamic> stop(
|
||||
String id, {
|
||||
String status = 'accepted',
|
||||
int parcels = 1,
|
||||
double lat = 11.0,
|
||||
double lng = 76.95,
|
||||
double cash = 0,
|
||||
}) => {
|
||||
'orderid': id,
|
||||
'pickupid': id,
|
||||
'orderstatus': status,
|
||||
'pickupquantity': parcels,
|
||||
'pickuplat': lat,
|
||||
'pickuplon': lng,
|
||||
'collectionamt': cash,
|
||||
};
|
||||
|
||||
group('what the figures count', () {
|
||||
test('a collected stop drops out of every figure', () {
|
||||
final trip = Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [
|
||||
stop('A', status: 'picked', lat: 11.00, lng: 76.90),
|
||||
stop('B', lat: 11.05, lng: 76.95),
|
||||
stop('C', lat: 11.10, lng: 77.00),
|
||||
],
|
||||
hubLat: 11.0,
|
||||
hubLng: 76.95,
|
||||
);
|
||||
|
||||
final left = trip.outstanding(originLat: 11.0, originLng: 76.95);
|
||||
|
||||
expect(trip.stopCount, 3, reason: 'the trip itself is untouched');
|
||||
expect(left.stopCount, 2);
|
||||
expect(left.totalParcels, lessThan(trip.totalParcels));
|
||||
expect(left.totalDuration, lessThan(trip.totalDuration));
|
||||
expect(left.routeMeters, lessThan(trip.routeMeters));
|
||||
});
|
||||
|
||||
test('the local collected record counts, not just the status', () {
|
||||
// The queue is a poll behind, so a stop the rider has just handed over
|
||||
// still reports its old status. `collectedIds` is what closes that
|
||||
// window — the same set Home writes after pickup-complete returns.
|
||||
final trip = Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [stop('A'), stop('B')],
|
||||
);
|
||||
|
||||
expect(trip.outstanding().stopCount, 2, reason: 'nothing collected yet');
|
||||
expect(trip.outstanding(collectedIds: const {'A'}).stopCount, 1);
|
||||
});
|
||||
|
||||
test('nothing collected returns the very same object', () {
|
||||
// The common case — a rider who has not started — must not pay for a
|
||||
// rebuild of the whole trip on every frame of the brief.
|
||||
final trip = Trip.fromStops(id: 't1', stops: [stop('A'), stop('B')]);
|
||||
expect(identical(trip.outstanding(), trip), isTrue);
|
||||
});
|
||||
|
||||
test('skipped and cancelled work is not still-to-do', () {
|
||||
final trip = Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [
|
||||
stop('A', status: 'skipped'),
|
||||
stop('B', status: 'cancelled'),
|
||||
stop('C'),
|
||||
],
|
||||
);
|
||||
expect(
|
||||
trip.outstanding().stopCount,
|
||||
1,
|
||||
reason: 'a written-off stop is a record, not a stop to plan around',
|
||||
);
|
||||
});
|
||||
|
||||
test('a run with everything collected leaves nothing to describe', () {
|
||||
final trip = Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [
|
||||
stop('A', status: 'picked'),
|
||||
stop('B', status: 'picked'),
|
||||
],
|
||||
);
|
||||
final left = trip.outstanding();
|
||||
expect(left.stopCount, 0);
|
||||
expect(left.totalParcels, 0);
|
||||
expect(
|
||||
left.totalDuration,
|
||||
Duration.zero,
|
||||
reason: 'the four cells fall back to the em dash, not to a stale figure',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('payment on a meal run', () {
|
||||
test('is nothing to collect, whatever the payload says', () {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
final trip = Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [stop('A', cash: 250), stop('B', cash: 130)],
|
||||
);
|
||||
// The cell prints the em dash when this is zero — the rider on a meal
|
||||
// run is never asked for money, so a figure here would be a prompt to
|
||||
// collect one.
|
||||
expect(trip.outstanding().cashToCollect, 0);
|
||||
});
|
||||
|
||||
test('but a parcel route still counts the cash it is owed', () {
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
final trip = Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [
|
||||
stop('A', status: 'picked', cash: 250),
|
||||
stop('B', cash: 130),
|
||||
],
|
||||
);
|
||||
expect(
|
||||
trip.outstanding().cashToCollect,
|
||||
130,
|
||||
reason: 'the collected stop\'s cash is no longer waiting to be taken',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -109,18 +109,28 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('the select-all label sits on the name axis', (tester) async {
|
||||
testWidgets('the select-all closes the row it belongs to', (tester) async {
|
||||
await pumpHome(tester);
|
||||
|
||||
// The regression this file exists for. It heads the list it selects, so
|
||||
// it starts where the list starts — not one rail further in, and not two
|
||||
// points off it either.
|
||||
final label = tester.getRect(find.text('Select all 3')).left;
|
||||
final name = tester.getRect(find.text('Customer 1')).left;
|
||||
// ── The axis it answers to is the right one now ──
|
||||
//
|
||||
// This pinned the label to the *name* axis, when the control headed its
|
||||
// list from the left. Inside a group it is a control over a list rather
|
||||
// than a heading for one, so it sits on the edge a list's own controls
|
||||
// use — the same right edge the bag labels and the chevrons below it
|
||||
// already keep. What must not happen is that it floats between the two.
|
||||
final label = tester.getRect(find.text('Select all 3')).right;
|
||||
final list = tester.getRect(find.byType(RouteTimeline)).right;
|
||||
final bag = tester.getRect(find.text('Bag 1')).right;
|
||||
expect(
|
||||
label,
|
||||
closeTo(name, 0.5),
|
||||
reason: 'the select-all is $label, the names are $name',
|
||||
closeTo(list, 4),
|
||||
reason: 'the select-all ends at $label, the list at $list',
|
||||
);
|
||||
expect(
|
||||
label,
|
||||
greaterThan(bag),
|
||||
reason: 'and it closes past the bag column rather than inside it',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -98,11 +99,20 @@ void main() {
|
||||
) async {
|
||||
await pump(tester);
|
||||
|
||||
// The brief's first ink is the hero numeral now — the caption sits to
|
||||
// its right — so the claim is measured on the numeral.
|
||||
// ── Measured on the metrics, not on the headline ──
|
||||
//
|
||||
// The headline is centred now: the card is symmetrical — a title over a
|
||||
// full-width bar — and a left-aligned headline over a centred bar reads as
|
||||
// two elements that failed to line up. So the brief's left edge is the one
|
||||
// its *left-aligned* content sits on, which is the metric grid under the
|
||||
// bar. That is the column the route below has to agree with.
|
||||
// The grid's first ink is the icon tile, not the word beside it.
|
||||
final brief = tester.getRect(
|
||||
find
|
||||
.descendant(of: find.byType(TripBriefStrip), matching: find.text('2'))
|
||||
.ancestor(
|
||||
of: find.byIcon(LucideIcons.clock),
|
||||
matching: find.byType(Container),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final route = tester.getRect(find.text('TODAY’S RUN'));
|
||||
|
||||
418
test/home_header_test.dart
Normal file
@@ -0,0 +1,418 @@
|
||||
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/home_header.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE HOME HEADER
|
||||
///
|
||||
/// Two things this file is for, and the second is the one that will save
|
||||
/// somebody later.
|
||||
///
|
||||
/// **The budget.** The header's whole justification is that the rider's first
|
||||
/// stop was below the fold. That is an arithmetic claim, so it is measured
|
||||
/// here: expanded content stays inside 150–175dp, and the collapsed state is
|
||||
/// 52. A header that creeps back up a line at a time is exactly how the
|
||||
/// original one got to 160.
|
||||
///
|
||||
/// **The extent contract.** A `SliverPersistentHeader` declares a fixed height
|
||||
/// and anything longer than it is *silently clipped* — no exception, no
|
||||
/// overflow stripe, just a trip selector with its bottom cut off. Two of the
|
||||
/// lines in here are type that grows with the system font, so the extent is
|
||||
/// derived rather than hardcoded, and these tests walk it from 1.0× to 2.0× on
|
||||
/// four screen widths to prove it is never short.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
/// Pumps the header as the page does: a pinned sliver at the head of a
|
||||
/// `CustomScrollView`, with something long under it to scroll.
|
||||
Future<ScrollController> pump(
|
||||
WidgetTester tester, {
|
||||
String greeting = 'Good afternoon, Rajan',
|
||||
String subtitle = '17 stops remaining',
|
||||
String collapsed = 'Trip 1 · 17 left · 33/50',
|
||||
bool onDuty = true,
|
||||
bool locked = false,
|
||||
bool busy = false,
|
||||
VoidCallback? onToggle,
|
||||
double width = 390,
|
||||
double textScale = 1.0,
|
||||
double topInset = 47,
|
||||
}) async {
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
tester.view.physicalSize = Size(width * 3, 844 * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(
|
||||
size: Size(width, 844),
|
||||
padding: EdgeInsets.only(top: topInset),
|
||||
textScaler: TextScaler.linear(textScale),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: HomeHeaderDelegate(
|
||||
topInset: topInset,
|
||||
expandedContent: HomeHeaderDelegate.expandedContentFor(
|
||||
context,
|
||||
),
|
||||
greeting: greeting,
|
||||
subtitle: subtitle,
|
||||
onDuty: onDuty,
|
||||
dutyLocked: locked,
|
||||
dutyBusy: busy,
|
||||
onToggleDuty: onToggle ?? () {},
|
||||
collapsedSummary: collapsed,
|
||||
),
|
||||
),
|
||||
SliverList.list(
|
||||
children: [
|
||||
for (var i = 0; i < 40; i++)
|
||||
SizedBox(height: 60, child: Text('row $i')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return controller;
|
||||
}
|
||||
|
||||
group('1 · the height budget', () {
|
||||
testWidgets('expanded content is inside 95–120dp', (tester) async {
|
||||
late double content;
|
||||
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
content = HomeHeaderDelegate.expandedContentFor(context);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// ── The budget moved when the tabs did ──
|
||||
//
|
||||
// It was 150–175, for a header that carried the trip selector. The
|
||||
// selector sits on the page under it now, so what is left up here is a
|
||||
// wordmark row, a greeting and one operational line — and the budget is
|
||||
// the sum of those rather than a number inherited from the old shape.
|
||||
//
|
||||
// The tabs are still charged, just not to this. Header + tabs together
|
||||
// are about 124 + 46 + their padding, against the 160 the fixed block
|
||||
// cost before — and unlike that block, 72 of it scrolls away.
|
||||
// ── The budget moved again when the duplicate line went ──
|
||||
//
|
||||
// It was 110–135 with `N stops remaining` under the greeting. That line
|
||||
// was the brief card's headline said twice, forty points apart, smaller
|
||||
// and greyer the first time — so it is gone and the header is ~20 points
|
||||
// cheaper. Every one of those points is the rider's first stop moving up
|
||||
// the screen, which is the whole reason this test exists.
|
||||
expect(
|
||||
content,
|
||||
inInclusiveRange(95, 120),
|
||||
reason:
|
||||
'measured $content — the header exists to get the first stop above '
|
||||
'the fold, and every point it grows takes one back',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('collapsed content is 52dp', (tester) async {
|
||||
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => const MaterialApp(home: SizedBox()),
|
||||
),
|
||||
);
|
||||
expect(HomeHeaderDelegate.collapsedContent, closeTo(52, 0.5));
|
||||
});
|
||||
});
|
||||
|
||||
group('2 · the extent is never short', () {
|
||||
for (final width in const [320.0, 360.0, 390.0, 430.0]) {
|
||||
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
|
||||
testWidgets('${width.toInt()}dp at ${scale}x', (tester) async {
|
||||
await pump(tester, width: width, textScale: scale);
|
||||
// A clipped sliver throws nothing, so the assertion that matters is
|
||||
// that the layout itself did not complain — an overflowing Column
|
||||
// inside the header WOULD throw, and that is the failure mode these
|
||||
// widths and scales are hunting.
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('ON'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('a long rider name does not push anything out', (tester) async {
|
||||
await pump(
|
||||
tester,
|
||||
width: 320,
|
||||
greeting: 'Good afternoon, Venkateswara Subramaniam',
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
// The greeting ellipsises to one line rather than wrapping and pushing
|
||||
// the header past its declared extent.
|
||||
expect(find.text('Good afternoon, Venkateswara Subramaniam'),
|
||||
findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('3 · what each state says', () {
|
||||
testWidgets('off duty reads OFF and keeps the same shape', (tester) async {
|
||||
await pump(tester, onDuty: false);
|
||||
expect(find.text('OFF'), findsOneWidget);
|
||||
expect(find.text('ON'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the stop count is NOT repeated up here', (tester) async {
|
||||
// ── It was said twice, forty points apart ──
|
||||
//
|
||||
// `17 stops remaining` sat under the greeting in grey 13pt, and the
|
||||
// brief card immediately below leads with `17 stops left` as its
|
||||
// headline at 17pt against a progress bar. Two sizes, two wordings, one
|
||||
// figure — and the smaller, greyer, vaguer one came first.
|
||||
//
|
||||
// The header carries identity and duty. The count belongs to the card
|
||||
// that also shows the duration, distance and parcels behind it.
|
||||
await pump(tester);
|
||||
expect(find.text('Good afternoon, Rajan'), findsOneWidget);
|
||||
expect(find.text('17 stops remaining'), findsNothing);
|
||||
expect(find.textContaining('stops remaining'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('but the collapsed line still carries it', (tester) async {
|
||||
// The one place the figure is still the header's job: collapsed, the
|
||||
// brief card has scrolled away and this is all that is left on screen.
|
||||
final c = await pump(tester);
|
||||
c.jumpTo(400);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.text('Trip 1 · 17 left · 33/50'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('4 · duty', () {
|
||||
testWidgets('the tap target clears 48dp even though the pill is smaller', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester);
|
||||
final target = tester.getSize(find.byType(DutyPill));
|
||||
expect(target.height, greaterThanOrEqualTo(DutyPill.tapTarget));
|
||||
});
|
||||
|
||||
testWidgets('tapping asks the page, it does not decide', (tester) async {
|
||||
var asked = 0;
|
||||
await pump(tester, onToggle: () => asked++);
|
||||
await tester.tap(find.byType(DutyPill));
|
||||
expect(asked, 1);
|
||||
});
|
||||
|
||||
testWidgets('a write in flight refuses a second press', (tester) async {
|
||||
var asked = 0;
|
||||
await pump(tester, busy: true, onToggle: () => asked++);
|
||||
await tester.tap(find.byType(DutyPill), warnIfMissed: false);
|
||||
expect(asked, 0, reason: 'two duty writes for one intent');
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('locked mid-pickup refuses, and says so', (tester) async {
|
||||
var asked = 0;
|
||||
await pump(tester, locked: true, onToggle: () => asked++);
|
||||
await tester.tap(find.byType(DutyPill), warnIfMissed: false);
|
||||
expect(asked, 0);
|
||||
// The state is still readable — the control dims rather than vanishing.
|
||||
expect(find.text('ON'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('5 · collapsing', () {
|
||||
testWidgets('the greeting goes and the operational line arrives', (
|
||||
tester,
|
||||
) async {
|
||||
final c = await pump(tester);
|
||||
|
||||
expect(find.text('Good afternoon, Rajan'), findsOneWidget);
|
||||
expect(find.text('Trip 1 · 17 left · 33/50'), findsNothing);
|
||||
|
||||
c.jumpTo(400);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
// Each layout is dropped entirely once its side of the cross-fade
|
||||
// reaches zero, rather than kept at `Opacity(0)`. A fully transparent
|
||||
// subtree still lays out and still paints into a saved layer on every
|
||||
// frame of a scroll, and this one holds a wordmark, a greeting and a
|
||||
// duty control — which is a lot of nothing to composite forty times a
|
||||
// second.
|
||||
expect(find.text('Good afternoon, Rajan'), findsNothing);
|
||||
expect(find.text('Trip 1 · 17 left · 33/50'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('duty stays reachable in the collapsed state', (tester) async {
|
||||
var asked = 0;
|
||||
final c = await pump(tester, onToggle: () => asked++);
|
||||
c.jumpTo(400);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
// The expanded copy is gone by now, so this can only be the collapsed
|
||||
// one — which is the point: duty is the one control that must never
|
||||
// scroll away.
|
||||
expect(find.byType(DutyPill), findsOneWidget);
|
||||
await tester.tap(find.byType(DutyPill));
|
||||
expect(asked, 1);
|
||||
});
|
||||
|
||||
testWidgets('the header holds the top of the screen', (tester) async {
|
||||
final c = await pump(tester);
|
||||
c.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
// The pinned extent is exactly the collapsed one plus the inset.
|
||||
final material = tester.getSize(
|
||||
find.byKey(HomeHeaderDelegate.surfaceKey),
|
||||
);
|
||||
expect(
|
||||
material.height,
|
||||
closeTo(47 + HomeHeaderDelegate.collapsedContent, 1),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('6 · rebuild scope', () {
|
||||
test('an unrelated change does not rebuild the header', () {
|
||||
HomeHeaderDelegate make({String subtitle = '17 stops remaining'}) =>
|
||||
HomeHeaderDelegate(
|
||||
topInset: 47,
|
||||
expandedContent: 170,
|
||||
greeting: 'Good afternoon, Rajan',
|
||||
subtitle: subtitle,
|
||||
onDuty: true,
|
||||
dutyLocked: false,
|
||||
dutyBusy: false,
|
||||
onToggleDuty: () {},
|
||||
collapsedSummary: 'Trip 1 · 17 left · 33/50',
|
||||
);
|
||||
|
||||
// Same inputs — a row further down the list changing touches none of
|
||||
// them, so the sliver does not repaint for it.
|
||||
expect(make().shouldRebuild(make()), isFalse);
|
||||
// A figure it actually draws does.
|
||||
expect(
|
||||
make().shouldRebuild(make(subtitle: '16 stops remaining')),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// THE COLLAPSE ITSELF
|
||||
//
|
||||
// The budget tests above measure the header at its two REST states. The bug
|
||||
// this group exists for lived entirely in between them.
|
||||
//
|
||||
// `_ExpandedBody` was a plain child of a `StackFit.expand` stack, so it was
|
||||
// handed the header's *current* height — which, mid-collapse, is less than
|
||||
// the content. Its Column cannot compress, so from the first pixel of scroll
|
||||
// Flutter drew `BOTTOM OVERFLOWED BY 28 PIXELS` in yellow and black across
|
||||
// the top of Home, over the greeting, on a rider's phone.
|
||||
//
|
||||
// Neither rest state shows it: fully expanded there is room, fully collapsed
|
||||
// the body has faded to nothing. Only the travel between them does — which
|
||||
// is why every assertion below walks the scroll rather than sampling its
|
||||
// ends.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('7 · nothing overflows while it collapses', () {
|
||||
/// Every offset from rest to fully pinned, finely enough that no fade
|
||||
/// threshold (0.55 / 0.6) can be stepped over.
|
||||
Future<void> walk(
|
||||
WidgetTester tester,
|
||||
ScrollController controller,
|
||||
double range,
|
||||
) async {
|
||||
for (var i = 0; i <= 40; i++) {
|
||||
controller.jumpTo(range * i / 40);
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester.takeException(),
|
||||
isNull,
|
||||
reason:
|
||||
'overflowed at scroll offset ${range * i / 40} of $range — the '
|
||||
'header must clip its content, never squeeze it',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('at 1.0x on a 390pt phone', (tester) async {
|
||||
final c = await pump(tester);
|
||||
await walk(tester, c, 200);
|
||||
});
|
||||
|
||||
testWidgets('at every text scale a rider can set', (tester) async {
|
||||
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
|
||||
final c = await pump(tester, textScale: scale);
|
||||
await walk(tester, c, 200);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('on a narrow phone, where the greeting wraps', (tester) async {
|
||||
// 320pt with a long name is where the greeting is most likely to take a
|
||||
// second line and push the Column past the extent.
|
||||
final c = await pump(
|
||||
tester,
|
||||
width: 320,
|
||||
greeting: 'Good afternoon, Rajanikanth Subramaniam',
|
||||
subtitle: '20 stops remaining',
|
||||
);
|
||||
await walk(tester, c, 200);
|
||||
});
|
||||
|
||||
testWidgets('the expanded body keeps its full height as it goes', (
|
||||
tester,
|
||||
) async {
|
||||
// The fix, stated as behaviour rather than as a widget choice: the
|
||||
// greeting does not shrink on the way out, it slides out. If it ever
|
||||
// reflows again, this catches it before the overflow banner does.
|
||||
final c = await pump(tester);
|
||||
final full = tester.getSize(find.text('Good afternoon, Rajan')).height;
|
||||
|
||||
c.jumpTo(30);
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(
|
||||
tester.getSize(find.text('Good afternoon, Rajan')).height,
|
||||
full,
|
||||
reason: 'the body is measured at its own height, not the header\'s',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -134,11 +134,30 @@ void main() {
|
||||
anyOf(isNull, isEmpty),
|
||||
reason: 'a shadow lifts a group off the page; the spine groups it',
|
||||
);
|
||||
expect(
|
||||
d.border,
|
||||
isNull,
|
||||
reason: 'an outline around a group is the card by another name',
|
||||
);
|
||||
// ── A rule between rows is not an outline around them ──
|
||||
//
|
||||
// This required `border` to be null outright, which caught the thing
|
||||
// it was written for — a rectangle drawn round a kitchen — and also
|
||||
// catches a one-sided hairline separating one order from the next.
|
||||
// Those are opposites: a box groups by enclosing, a rule separates by
|
||||
// dividing, and the second is the spine's own grammar rather than a
|
||||
// competitor to it. What stays banned is an enclosure.
|
||||
final border = d.border;
|
||||
if (border is Border) {
|
||||
expect(
|
||||
border.left.style == BorderStyle.none &&
|
||||
border.right.style == BorderStyle.none &&
|
||||
border.top.style == BorderStyle.none,
|
||||
isTrue,
|
||||
reason: 'an outline around a group is the card by another name',
|
||||
);
|
||||
} else {
|
||||
expect(
|
||||
border,
|
||||
isNull,
|
||||
reason: 'an outline around a group is the card by another name',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── What a fill may be here, since the route moved onto a panel ──
|
||||
|
||||
@@ -5,12 +5,31 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:miler/views/helpers/constants/app_theme.dart';
|
||||
import 'package:miler/views/introscreens/introscreen.dart';
|
||||
|
||||
/// The first-run slides. The illustrations are miniature mock-ups of the real
|
||||
/// Home screen rather than a centred Material icon, which means they are made of
|
||||
/// fixed-width rows — the hub rail on slide 1 is a dozen sized children in a
|
||||
/// single unflexed `Row`. That is exactly the shape that overflows on a narrow
|
||||
/// phone, so every slide is laid out at the narrowest screen the app supports.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE FIRST-RUN SLIDES
|
||||
///
|
||||
/// These used to be miniature mock-ups of Home built out of fixed-width rows —
|
||||
/// the hub rail on slide 1 was a dozen sized children in a single unflexed
|
||||
/// `Row` — and the tests existed because that is exactly the shape which
|
||||
/// overflows on a narrow phone.
|
||||
///
|
||||
/// The slides are finished posters now: headline, sub-copy, product shot and
|
||||
/// the MILER lockup are all baked into the artwork, and the screen draws no
|
||||
/// text of its own. A `BoxFit.cover` image cannot overflow, so the width sweep
|
||||
/// is no longer about the illustrations — it is about the controls laid over
|
||||
/// them, which are the only Flutter-built objects left on the screen.
|
||||
///
|
||||
/// What is worth pinning instead is the part a wrong asset path would break
|
||||
/// silently: three specific images, in the order the rider is meant to see
|
||||
/// them, with the CTA changing on the last one.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
const slides = <String>[
|
||||
'assets/images/onboard_1.png',
|
||||
'assets/images/onboard_2.png',
|
||||
'assets/images/onboard_3.png',
|
||||
];
|
||||
|
||||
Future<void> pump(WidgetTester tester, {required Size screen}) async {
|
||||
tester.view.physicalSize = Size(screen.width * 3, screen.height * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
@@ -27,6 +46,18 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The asset the slide currently on screen is drawing.
|
||||
String? shownAsset(WidgetTester tester) {
|
||||
for (final image in tester.widgetList<Image>(find.byType(Image))) {
|
||||
final provider = image.image;
|
||||
if (provider is AssetImage &&
|
||||
provider.assetName.startsWith('assets/images/onboard_')) {
|
||||
return provider.assetName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (final size in const [
|
||||
Size(320, 640), // smallest Android still in the fleet
|
||||
Size(360, 740),
|
||||
@@ -39,45 +70,104 @@ void main() {
|
||||
await pump(tester, screen: size);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// Slide 1 — the slot, its load, and the round trip.
|
||||
expect(find.text('Your route arrives ready'), findsOneWidget);
|
||||
expect(find.text('12:00 PM – 3:00 PM'), findsOneWidget);
|
||||
expect(shownAsset(tester), slides[0]);
|
||||
expect(find.text('Next'), findsOneWidget);
|
||||
expect(find.text('Skip'), findsOneWidget);
|
||||
|
||||
// A near-full-width drag, so the page always snaps to the next one. A
|
||||
// velocity fling does not reliably cross the threshold here, because each
|
||||
// slide hosts its own vertical scroll view and the two share the arena.
|
||||
// velocity fling does not reliably cross the threshold here.
|
||||
final swipe = Offset(-size.width * 0.9, 0);
|
||||
|
||||
await tester.drag(find.byType(PageView), swipe);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Work the stops in order'), findsOneWidget);
|
||||
expect(find.text('Skipped'), findsOneWidget);
|
||||
expect(shownAsset(tester), slides[1]);
|
||||
expect(find.text('Next'), findsOneWidget);
|
||||
|
||||
await tester.drag(find.byType(PageView), swipe);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Collect, then close the loop'), findsOneWidget);
|
||||
expect(find.text('₹340 cash'), findsOneWidget);
|
||||
expect(shownAsset(tester), slides[2]);
|
||||
|
||||
// Last slide swaps the CTA and drops Skip.
|
||||
// Last slide swaps the CTA and drops Skip — an invisible-but-present
|
||||
// control is a trap for screen readers, so it is unmounted rather than
|
||||
// faded to `opacity: 0`.
|
||||
expect(find.text('Get started'), findsOneWidget);
|
||||
expect(find.text('Next'), findsNothing);
|
||||
expect(find.text('Skip'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('the three posters are in the order they were given', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester, screen: const Size(390, 844));
|
||||
|
||||
// `PageView.builder` only builds the page on screen, so the order has to be
|
||||
// walked rather than read out of the tree in one go. Which is the honest
|
||||
// test anyway: what matters is the sequence the rider is shown, and 1-3-2
|
||||
// would pass any assertion made against a set.
|
||||
final seen = <String?>[shownAsset(tester)];
|
||||
for (var i = 1; i < slides.length; i++) {
|
||||
await tester.drag(find.byType(PageView), const Offset(-351, 0));
|
||||
await tester.pumpAndSettle();
|
||||
seen.add(shownAsset(tester));
|
||||
}
|
||||
|
||||
expect(seen, slides);
|
||||
});
|
||||
|
||||
testWidgets('the artwork runs edge to edge', (tester) async {
|
||||
await pump(tester, screen: const Size(390, 844));
|
||||
|
||||
final image = tester.widget<Image>(
|
||||
find
|
||||
.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is Image &&
|
||||
w.image is AssetImage &&
|
||||
(w.image as AssetImage).assetName == slides[0],
|
||||
)
|
||||
.first,
|
||||
);
|
||||
|
||||
// `cover` is what makes a 940×1672 poster fill a phone of any aspect
|
||||
// without letterboxing. `contain` would put bars down the sides, which on
|
||||
// artwork that ends in a red wave reads as a rendering fault.
|
||||
expect(image.fit, BoxFit.cover);
|
||||
});
|
||||
|
||||
testWidgets('the screen adds no captions of its own', (tester) async {
|
||||
await pump(tester, screen: const Size(390, 844));
|
||||
|
||||
// Every slide's headline and sub-copy are baked into the image. A Flutter
|
||||
// caption under the picture would say the same thing twice, in a second
|
||||
// typeface, at a second size — so the only text on the screen is the two
|
||||
// controls.
|
||||
final texts = tester
|
||||
.widgetList<Text>(find.byType(Text))
|
||||
.map((t) => t.data)
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
|
||||
expect(texts, <String>{'Skip', 'Next'});
|
||||
});
|
||||
|
||||
testWidgets('Skip is offered on the first slide', (tester) async {
|
||||
await pump(tester, screen: const Size(390, 844));
|
||||
expect(find.text('Skip'), findsOneWidget);
|
||||
});
|
||||
|
||||
// The old copy pitched a gig marketplace — "pickups near you", accepted from a
|
||||
// feed. This product assigns routes from a hub. Onboarding that teaches the
|
||||
// wrong model is worse than none, so the wrong words are pinned out.
|
||||
testWidgets('does not promise a marketplace the app does not have', (
|
||||
tester,
|
||||
) async {
|
||||
// The old code-drawn copy pitched a gig marketplace — "pickups near you",
|
||||
// accepted from a feed. This product assigns routes from a hub, and
|
||||
// onboarding that teaches the wrong model is worse than none.
|
||||
//
|
||||
// NOTE: this can only speak for text the *app* draws. The posters carry their
|
||||
// own headlines as pixels, and `onboard_1.png` does print "Powered by AI" —
|
||||
// a claim the previous slides deliberately dropped because nothing in the app
|
||||
// backs it. That is a copy decision about the artwork, not something a widget
|
||||
// test can hold.
|
||||
testWidgets('the app draws no marketplace copy of its own', (tester) async {
|
||||
await pump(tester, screen: const Size(390, 844));
|
||||
for (final wrong in [
|
||||
'Find pickups near you',
|
||||
@@ -85,11 +175,7 @@ void main() {
|
||||
'Earn more, every shift',
|
||||
'Powered by AI',
|
||||
]) {
|
||||
expect(
|
||||
find.text(wrong),
|
||||
findsNothing,
|
||||
reason: '"$wrong" is not this app',
|
||||
);
|
||||
expect(find.text(wrong), findsNothing, reason: '"$wrong" is not this app');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
865
test/miler_contract_regression_test.dart
Normal file
@@ -0,0 +1,865 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/controllers/riderkm.dart';
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/data/route_order.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/home/homepage.dart';
|
||||
import 'package:miler/data/stop_compliance.dart';
|
||||
import 'package:miler/controllers/pickups_controller.dart';
|
||||
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// FOUR CONTRACTS THE APP MEETS THE BACKEND ON
|
||||
///
|
||||
/// Each of these has already been wrong once, and each was wrong in the same
|
||||
/// way: the app read a field the server does not send, or ignored one it does.
|
||||
/// None of them raised anything — an absent key and an empty answer are the
|
||||
/// same value, so every failure was silent and every symptom was cosmetic
|
||||
/// ("the chart is empty", "the route is in the wrong order"). That is what
|
||||
/// makes them worth pinning rather than trusting.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 1 · ROUTE SEQUENCING
|
||||
//
|
||||
// The backend's rule, given 25 Aug: `sequencedat` is the authority, not
|
||||
// `step`. Non-null → a route was assigned, follow `step` exactly. Null → no
|
||||
// route, and nearest-first is correct rather than a fallback for a bug.
|
||||
//
|
||||
// `RouteOrder` implemented that correctly and it never ran, because
|
||||
// `pickupFromBooking` was dropping the field. Every adapted row looked
|
||||
// unsequenced and the app re-sorted routes the hub had solved.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('1 · sequencedat survives the adapter and decides the order', () {
|
||||
Map<String, dynamic> booking(int id, Map<String, dynamic> extra) => {
|
||||
'bookingid': id,
|
||||
'bookingno': 'B$id',
|
||||
'pickupaddress': '$id Thadagam Road, Coimbatore',
|
||||
'pickupcustomer': 'Sri Balaji Stores',
|
||||
...extra,
|
||||
};
|
||||
|
||||
/// The rider is beside stop 3, so nearest-first would put it first. Any
|
||||
/// test that expects the hub's order has to be able to lose to proximity
|
||||
/// if the gate fails — otherwise it passes for the wrong reason.
|
||||
double distanceTo(Map<String, dynamic> s) => switch ('${s['orderid']}') {
|
||||
'3' => 10,
|
||||
'1' => 4000,
|
||||
'2' => 9000,
|
||||
_ => 99999,
|
||||
};
|
||||
|
||||
test('a sequenced booking keeps its stamp and its step', () {
|
||||
final row = ApiConfig.pickupFromBooking(
|
||||
booking(1, {'step': 2, 'sequencedat': '2026-08-25T09:00:00+05:30'}),
|
||||
);
|
||||
|
||||
expect(
|
||||
RouteOrder.isSequenced(row),
|
||||
isTrue,
|
||||
reason: 'the stamp did not survive pickupFromBooking',
|
||||
);
|
||||
expect(RouteOrder.sequenceOf(row), 2);
|
||||
});
|
||||
|
||||
test('the hub order is followed, and proximity does not override it', () {
|
||||
final stops = [
|
||||
for (final (id, step) in const [(3, 3), (1, 1), (2, 2)])
|
||||
ApiConfig.pickupFromBooking(
|
||||
booking(id, {
|
||||
'step': step,
|
||||
'sequencedat': '2026-08-25T09:00:00+05:30',
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
final (sorted, source) = RouteOrder.sort(stops, distanceTo: distanceTo);
|
||||
|
||||
expect(source, RouteOrderSource.adminSequence);
|
||||
expect(
|
||||
sorted.map((s) => '${s['orderid']}').toList(),
|
||||
['1', '2', '3'],
|
||||
reason:
|
||||
'the rider is standing on B3 — if this comes back B3-first the '
|
||||
'stamp was ignored and nearest-first won',
|
||||
);
|
||||
});
|
||||
|
||||
test('no stamp means no route, and the fallback is correct', () {
|
||||
// The backend's own words: `step: 0` with a null stamp is a rider holding
|
||||
// fewer than two active stops, or a stop with no coordinates. Neither is
|
||||
// a route, and re-sorting is the right answer rather than a workaround.
|
||||
final stops = [
|
||||
for (final id in const [1, 2, 3])
|
||||
ApiConfig.pickupFromBooking(
|
||||
booking(id, {'step': 0, 'sequencedat': null}),
|
||||
),
|
||||
];
|
||||
|
||||
expect(RouteOrder.hasAdminSequence(stops), isFalse);
|
||||
|
||||
final (sorted, source) = RouteOrder.sort(stops, distanceTo: distanceTo);
|
||||
expect(source, isNot(RouteOrderSource.adminSequence));
|
||||
expect(
|
||||
'${sorted.first['orderid']}',
|
||||
'3',
|
||||
reason: 'with no route assigned, the nearest stop leads',
|
||||
);
|
||||
});
|
||||
|
||||
test('a blank stamp is no stamp', () {
|
||||
final row = ApiConfig.pickupFromBooking(
|
||||
booking(1, {'step': 0, 'sequencedat': ' '}),
|
||||
);
|
||||
expect(RouteOrder.isSequenced(row), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 2 · PER-STOP DISTANCE
|
||||
//
|
||||
// Three spellings, because the app writes two of them itself: the contract's
|
||||
// `compliance.actualkm`, the backend's `riderkms` on a raw booking row, and
|
||||
// `actualkms` — what `PickupsController` posts on the arrival and pickup
|
||||
// writes, so it is the key on any record stamped locally before the queue
|
||||
// caught up. Reading only the first two made a stop the rider had just
|
||||
// finished report no distance at all.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('2 · every distance spelling resolves', () {
|
||||
test('compliance.actualkm — the contract\'s own', () {
|
||||
final c = StopCompliance.of({
|
||||
'compliance': {'actualkm': 4.2},
|
||||
});
|
||||
expect(c.actualKm, 4.2);
|
||||
});
|
||||
|
||||
test('riderkms — a row straight off the API', () {
|
||||
expect(StopCompliance.of({'riderkms': 3.1}).actualKm, 3.1);
|
||||
});
|
||||
|
||||
test('actualkms — a row this app stamped itself', () {
|
||||
expect(StopCompliance.of({'actualkms': '2.75'}).actualKm, 2.75);
|
||||
});
|
||||
|
||||
test('the contract wins when more than one is present', () {
|
||||
final c = StopCompliance.of({
|
||||
'compliance': {'actualkm': 9.9},
|
||||
'riderkms': 3.1,
|
||||
'actualkms': '2.75',
|
||||
});
|
||||
expect(
|
||||
c.actualKm,
|
||||
9.9,
|
||||
reason: 'the authoritative figure must outrank the locally stamped one',
|
||||
);
|
||||
});
|
||||
|
||||
test('riderkms outranks the local stamp', () {
|
||||
final c = StopCompliance.of({'riderkms': 3.1, 'actualkms': '2.75'});
|
||||
expect(c.actualKm, 3.1);
|
||||
});
|
||||
|
||||
test('no distance at all is null, not zero', () {
|
||||
// Null is what the UI turns into the em dash. A zero would render as a
|
||||
// real measurement of nothing, which is a different claim.
|
||||
expect(StopCompliance.of(const {}).actualKm, isNull);
|
||||
expect(StopCompliance.of({'riderkms': 0}).actualKm, isNull);
|
||||
expect(StopCompliance.of({'actualkms': ''}).actualKm, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 3 · THE WEEK'S DISTANCE
|
||||
//
|
||||
// `GET /miler/earnings` answers with six totals for the period asked for and
|
||||
// no series. The app read a `breakdown` array that is not on the contract, so
|
||||
// it was always null and the chart drew an empty week while the rider had
|
||||
// ridden all of it.
|
||||
//
|
||||
// Seven daily calls build the week now. The fast path stays, so the day the
|
||||
// field ships the seven calls stop — and that switch has to be automatic and
|
||||
// has to refuse anything it cannot draw.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('3 · weekly distance: fast path and fallback', () {
|
||||
late List<Uri> asked;
|
||||
|
||||
/// Answers `weekly` with [weekly] and every `daily` with its own total.
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
void stub({
|
||||
Map<String, dynamic> weekly = const {'total_kms': 40},
|
||||
double dailyKm = 5,
|
||||
}) {
|
||||
asked = <Uri>[];
|
||||
MilerApi.client = MockClient((req) async {
|
||||
asked.add(req.url);
|
||||
final period = req.url.queryParameters['period'];
|
||||
final body = period == 'weekly' ? weekly : {'total_kms': dailyKm};
|
||||
return http.Response(
|
||||
jsonEncode({'success': true, 'data': body}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
int dailyCalls() =>
|
||||
asked.where((u) => u.queryParameters['period'] == 'daily').length;
|
||||
|
||||
test(
|
||||
'FAST PATH · a valid breakdown is used, and no daily call is made',
|
||||
() async {
|
||||
stub(
|
||||
weekly: {
|
||||
'total_kms': 40.5,
|
||||
// The backend's own example shape: ISO dates, not weekday names.
|
||||
'breakdown': [
|
||||
{'day': '2026-08-19', 'kms': 18.4},
|
||||
{'day': '2026-08-20', 'kms': 22.1},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
||||
final details = res['details'] as List;
|
||||
|
||||
expect(details, hasLength(2));
|
||||
expect(res['total_kms'], 40.5);
|
||||
expect(
|
||||
dailyCalls(),
|
||||
0,
|
||||
reason: 'the seven daily calls must stop the day breakdown ships',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('FAST PATH · an ISO date is labelled with the day it names', () async {
|
||||
stub(
|
||||
weekly: {
|
||||
'total_kms': 18.4,
|
||||
'breakdown': [
|
||||
{'day': '2026-08-19', 'kms': 18.4},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
||||
final first = (res['details'] as List).first;
|
||||
|
||||
// 2026-08-19 is a Wednesday. The chart labels a bar with the first three
|
||||
// characters of `day`, so an un-normalised ISO date renders as `202`.
|
||||
expect(first.day, 'Wed');
|
||||
expect(first.kms, 18.4);
|
||||
});
|
||||
|
||||
test('FAST PATH · a weekday name is passed through untouched', () async {
|
||||
stub(
|
||||
weekly: {
|
||||
'breakdown': [
|
||||
{'day': 'Mon', 'kms': 3},
|
||||
],
|
||||
},
|
||||
);
|
||||
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
||||
expect((res['details'] as List).first.day, 'Mon');
|
||||
expect(dailyCalls(), 0);
|
||||
});
|
||||
|
||||
for (final (name, bad) in <(String, Object?)>[
|
||||
('absent', null),
|
||||
('empty', <dynamic>[]),
|
||||
('not a list', 'nope'),
|
||||
('entries that are not maps', ['Mon', 'Tue']),
|
||||
(
|
||||
'an entry with no day',
|
||||
[
|
||||
{'kms': 4},
|
||||
],
|
||||
),
|
||||
(
|
||||
'an entry whose distance will not parse',
|
||||
[
|
||||
{'day': 'Mon', 'kms': 'lots'},
|
||||
],
|
||||
),
|
||||
]) {
|
||||
test('FALLBACK · $name breakdown falls through to seven calls', () async {
|
||||
stub(weekly: {'total_kms': 35, if (bad != null) 'breakdown': bad});
|
||||
|
||||
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
||||
final details = res['details'] as List;
|
||||
|
||||
expect(
|
||||
dailyCalls(),
|
||||
7,
|
||||
reason: 'a series the chart cannot draw is not a series',
|
||||
);
|
||||
expect(details, hasLength(7));
|
||||
expect(
|
||||
details.every((d) => d.kms == 5),
|
||||
isTrue,
|
||||
reason: 'each bar is its own day\'s total_kms',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'FALLBACK · the daily series is labelled and ordered oldest first',
|
||||
() async {
|
||||
stub();
|
||||
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
||||
final days = (res['details'] as List).map((d) => d.day).toList();
|
||||
|
||||
expect(days, hasLength(7));
|
||||
expect(days.toSet(), hasLength(7), reason: 'seven distinct weekdays');
|
||||
expect(
|
||||
days.last,
|
||||
const [
|
||||
'Mon',
|
||||
'Tue',
|
||||
'Wed',
|
||||
'Thu',
|
||||
'Fri',
|
||||
'Sat',
|
||||
'Sun',
|
||||
][DateTime.now().weekday - 1],
|
||||
reason: 'the last bar is today',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('FALLBACK · the server\'s weekly total is not overruled', () async {
|
||||
// Seven daily figures are a reconstruction. The weekly number the backend
|
||||
// computed is the one that stands.
|
||||
stub(weekly: {'total_kms': 99}, dailyKm: 5);
|
||||
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
||||
expect(res['total_kms'], 99);
|
||||
});
|
||||
|
||||
test(
|
||||
'FALLBACK · with no weekly total, the reconstruction stands in',
|
||||
() async {
|
||||
stub(weekly: {'total_kms': 0}, dailyKm: 5);
|
||||
final res = await RiderWeeklyKmController().getRiderWeeklyKms(1);
|
||||
expect(res['total_kms'], 35);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 3b · THE TWO LIFECYCLE WRITES ACTUALLY LEAVE THE PHONE
|
||||
//
|
||||
// The defect these pin: the map sheet's **I've arrived** control never called
|
||||
// the arrival API. `updateArrivedStatus` was reachable from `homepage.dart`
|
||||
// and nowhere else, so on the logistics line — where the map sheet is the
|
||||
// only arrival control — `reached` never fired, the local record was never
|
||||
// written, and the rung went from ACCEPTED straight to whatever came next.
|
||||
//
|
||||
// Nothing raised. A transition that is never attempted looks exactly like one
|
||||
// that succeeded and was not reflected, which is why this was read as a
|
||||
// status-mapping bug for as long as it was.
|
||||
//
|
||||
// These assert the wire, not the widget: the endpoint, the identifier in the
|
||||
// path, and the body — with the geofence bypass ON, which is the state the
|
||||
// build ships in and the thing that was suspected of eating the call.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('3b · the arrival and pickup writes reach the network', () {
|
||||
late List<http.Request> sent;
|
||||
|
||||
void stub() {
|
||||
sent = <http.Request>[];
|
||||
MilerApi.client = MockClient((req) async {
|
||||
sent.add(req);
|
||||
return http.Response(
|
||||
jsonEncode({'success': true, 'data': {}}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
stub();
|
||||
});
|
||||
tearDown(() => MilerApi.client = http.Client());
|
||||
|
||||
http.Request only(String fragment) {
|
||||
final hits = sent.where((r) => r.url.path.contains(fragment)).toList();
|
||||
expect(
|
||||
hits,
|
||||
hasLength(1),
|
||||
reason:
|
||||
'expected exactly one request to …/$fragment, sent: '
|
||||
'${sent.map((r) => r.url.path).toList()}',
|
||||
);
|
||||
return hits.single;
|
||||
}
|
||||
|
||||
test('ARRIVED · endpoint, path id and body', () async {
|
||||
await MilerApi.reached(4211, lat: 11.0168, lon: 76.9558);
|
||||
|
||||
final req = only('reached');
|
||||
expect(req.method, 'POST');
|
||||
// The **booking** id, in the path. Not the assignment id — that one keys
|
||||
// accept/reject only — and not a consignment, which does not exist yet.
|
||||
expect(req.url.path, endsWith('/miler/bookings/4211/reached'));
|
||||
expect(jsonDecode(req.body), {'latitude': 11.0168, 'longitude': 76.9558});
|
||||
});
|
||||
|
||||
test(
|
||||
'ARRIVED · a fix-less arrival still posts, with no coordinates',
|
||||
() async {
|
||||
// The body omits what it does not have rather than sending zeros, which
|
||||
// would be a claim about where the rider was standing.
|
||||
await MilerApi.reached(4211);
|
||||
expect(jsonDecode(only('reached').body), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('PICKED · endpoint, path id and body', () async {
|
||||
await MilerApi.pickupComplete(4211, lat: 11.0168, lon: 76.9558);
|
||||
|
||||
final req = only('pickup-complete');
|
||||
expect(req.method, 'POST');
|
||||
expect(req.url.path, endsWith('/miler/bookings/4211/pickup-complete'));
|
||||
expect(jsonDecode(req.body), {'latitude': 11.0168, 'longitude': 76.9558});
|
||||
expect(
|
||||
req.headers['Idempotency-Key'],
|
||||
isNotNull,
|
||||
reason:
|
||||
'the pivot is the one write a dropped acknowledgement must not '
|
||||
'be able to run twice',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'the provider routes ARRIVED to reached, not to anything else',
|
||||
() async {
|
||||
await UpdatePickupProvider().updatePickup({
|
||||
'pickupid': 4211,
|
||||
'orderheaderid': 77,
|
||||
'orderstatus': 'arrived',
|
||||
'riderslat': '11.016800',
|
||||
'riderslon': '76.955800',
|
||||
});
|
||||
|
||||
final req = only('reached');
|
||||
// `pickupid` IS the booking id — see `ApiConfig.pickupFromBooking`, which
|
||||
// maps `bookingid` onto it. This pins that, because the provider picks
|
||||
// the path id off the payload and a change there would be silent.
|
||||
expect(req.url.path, endsWith('/miler/bookings/4211/reached'));
|
||||
expect(jsonDecode(req.body), {
|
||||
'latitude': 11.0168,
|
||||
'longitude': 76.9558,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('the provider routes PICKED to pickup-complete', () async {
|
||||
await UpdatePickupProvider().updatePickup({
|
||||
'pickupid': 4211,
|
||||
'orderheaderid': 77,
|
||||
'orderstatus': 'picked',
|
||||
'riderslat': '11.016800',
|
||||
'riderslon': '76.955800',
|
||||
});
|
||||
|
||||
expect(
|
||||
only('pickup-complete').url.path,
|
||||
endsWith('/miler/bookings/4211/pickup-complete'),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'a missing booking id refuses the call, it does not post to zero',
|
||||
() async {
|
||||
// ── The silent one ──
|
||||
//
|
||||
// The provider resolved its path id with `pickupid ?? orderheaderid`, and
|
||||
// `??` catches only null. Every caller builds the payload with
|
||||
// `int.tryParse(...) ?? 0`, so a row without a booking id arrived as
|
||||
// **0** — not null — the fallback never fired, and the app posted to
|
||||
// `/miler/bookings/0/reached`.
|
||||
//
|
||||
// Nothing complained on the phone: arrival is the rider's own report and
|
||||
// is deliberately not gated on the write landing, so his rung advanced
|
||||
// and the row looked healthy. The hub was told about booking zero, and
|
||||
// the console showed no arrival — which is exactly how this was reported.
|
||||
final res = await UpdatePickupProvider().updatePickup({
|
||||
'pickupid': 0,
|
||||
'orderheaderid': 0,
|
||||
'orderstatus': 'arrived',
|
||||
'riderslat': '11.016800',
|
||||
'riderslon': '76.955800',
|
||||
});
|
||||
|
||||
expect(sent, isEmpty, reason: 'booking 0 is not a booking');
|
||||
expect(res?['status'], isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a real id still posts, and zero on the other key is ignored',
|
||||
() async {
|
||||
await UpdatePickupProvider().updatePickup({
|
||||
'pickupid': 0,
|
||||
'orderheaderid': 77,
|
||||
'orderstatus': 'arrived',
|
||||
'riderslat': '11.016800',
|
||||
'riderslon': '76.955800',
|
||||
});
|
||||
expect(
|
||||
only('reached').url.path,
|
||||
endsWith('/miler/bookings/77/reached'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('the geofence bypass does not stop the write leaving', () async {
|
||||
// The suspicion this rules out. `_checkGeofence` returns true early when
|
||||
// enforcement is off and its success branch contains nothing else — so
|
||||
// the bypass skips the distance *rejection* and nothing more.
|
||||
//
|
||||
// Asserted through the provider rather than by reading the flag, because
|
||||
// "the request went out with the fence off" is the claim, and the flag is
|
||||
// only evidence for it.
|
||||
expect(kBypassGeofenceForTesting, isTrue);
|
||||
|
||||
await UpdatePickupProvider().updatePickup({
|
||||
'pickupid': 4211,
|
||||
'orderstatus': 'arrived',
|
||||
'riderslat': '11.016800',
|
||||
'riderslon': '76.955800',
|
||||
});
|
||||
|
||||
expect(sent, isNotEmpty, reason: 'the bypass swallowed the request');
|
||||
expect(only('reached').url.path, contains('/4211/reached'));
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 3c · THE PICKUP MILESTONE IS NOT THE DELIVERY STATUS
|
||||
//
|
||||
// The backend's booking lifecycle, confirmed by them and with no Arrived rung
|
||||
// anywhere in it:
|
||||
//
|
||||
// pending → miler_assigned → pickup_scheduled → converted_to_consignment
|
||||
// → out_for_delivery / active → delivered
|
||||
//
|
||||
// Three of those words carry the **pickup** milestone and all three parsed as
|
||||
// `unknown`: `pickup_scheduled`, `converted_to_consignment` and `picked_up`.
|
||||
// So the one field the pickup UI reads had nothing in it, and `active` — a
|
||||
// *delivery* state, which does parse — won by default. That is the second
|
||||
// half of "Picked shows as Active"; the first was the app writing `active`
|
||||
// onto the rung itself.
|
||||
//
|
||||
// Nothing about the backend's meanings is changed here. `active` still means
|
||||
// active, and the delivery surfaces still read it that way. What is fixed is
|
||||
// that the pickup surfaces resolve the *pickup* fact and it outranks a
|
||||
// delivery state that came after it.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('3c · the pickup rung, derived from authoritative facts', () {
|
||||
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
Map<String, dynamic> row(String status) => {
|
||||
'orderid': 'B1',
|
||||
'pickupid': 'B1',
|
||||
'sourcename': 'Vidhya Kitchen',
|
||||
'orderstatus': status,
|
||||
};
|
||||
|
||||
StopStatus rung(
|
||||
String status, {
|
||||
Set<String> collected = const {},
|
||||
Set<String> arrived = const {},
|
||||
Set<String> released = const {},
|
||||
Set<String> accepted = const {'B1'},
|
||||
}) => MilkRun.stageOf(
|
||||
row(status),
|
||||
acceptedIds: accepted,
|
||||
collectedIds: collected,
|
||||
arrivedIds: arrived,
|
||||
outForDeliveryIds: released,
|
||||
);
|
||||
|
||||
test('the backend\'s pickup words parse at all', () {
|
||||
// Each of these was StopStatus.unknown.
|
||||
expect(stopStatusFromRaw('pickup_scheduled'), StopStatus.accepted);
|
||||
expect(stopStatusFromRaw('converted_to_consignment'), StopStatus.picked);
|
||||
expect(stopStatusFromRaw('picked_up'), StopStatus.picked);
|
||||
});
|
||||
|
||||
test('pickup_scheduled, nothing else → Accepted', () {
|
||||
expect(rung('pickup_scheduled'), StopStatus.accepted);
|
||||
});
|
||||
|
||||
test('pickup_scheduled + an arrival fact → Arrived', () {
|
||||
expect(
|
||||
rung('pickup_scheduled', arrived: {'B1'}),
|
||||
StopStatus.arrived,
|
||||
reason:
|
||||
'the arrival is the rider\'s own record; nothing outranks it '
|
||||
'here because nothing further along has happened',
|
||||
);
|
||||
});
|
||||
|
||||
test('converted_to_consignment → Picked', () {
|
||||
expect(rung('converted_to_consignment'), StopStatus.picked);
|
||||
});
|
||||
|
||||
test('picked_up → Picked', () {
|
||||
expect(rung('picked_up'), StopStatus.picked);
|
||||
});
|
||||
|
||||
test('active + pickup evidence → Picked on the pickup UI', () {
|
||||
// The case the whole separation exists for. The delivery lifecycle has
|
||||
// moved on; the pickup milestone did not un-happen.
|
||||
expect(rung('active', collected: {'B1'}), StopStatus.picked);
|
||||
});
|
||||
|
||||
test('out_for_delivery + pickup evidence → Picked on the pickup UI', () {
|
||||
expect(rung('out_for_delivery', collected: {'B1'}), StopStatus.picked);
|
||||
});
|
||||
|
||||
test('delivered keeps its own rung, not the pickup one', () {
|
||||
// A delivered stop is finished. The pickup milestone is history, and the
|
||||
// rung a rider is shown is the one that is current.
|
||||
expect(rung('delivered', collected: {'B1'}), StopStatus.delivered);
|
||||
});
|
||||
|
||||
test('active WITHOUT pickup evidence stays active', () {
|
||||
// The line the brief drew: do not infer Picked merely because a booking
|
||||
// is Active. Without a collection fact there is nothing to infer from.
|
||||
//
|
||||
// `accepted` is emptied here on purpose: the local accept store is
|
||||
// checked below `active` in the ladder, so leaving it in would answer
|
||||
// `accepted` and the test would pass for the wrong reason.
|
||||
expect(rung('active', accepted: const {}), StopStatus.active);
|
||||
});
|
||||
|
||||
test('the delivery resolver still reads active as active', () {
|
||||
// Nothing about the backend's meaning is being renamed.
|
||||
expect(stopStatusFromRaw('active'), StopStatus.active);
|
||||
expect(stopStatusFromRaw('out_for_delivery'), StopStatus.outForDelivery);
|
||||
});
|
||||
|
||||
test('an arrival record cannot walk a picked stop backwards', () {
|
||||
expect(
|
||||
rung('converted_to_consignment', arrived: {'B1'}, collected: {'B1'}),
|
||||
StopStatus.picked,
|
||||
);
|
||||
expect(
|
||||
rung('out_for_delivery', arrived: {'B1'}, released: {'B1'}),
|
||||
StopStatus.outForDelivery,
|
||||
);
|
||||
expect(rung('delivered', arrived: {'B1'}), StopStatus.delivered);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 3d · START DELIVERY IS THE ONLY THING THAT MAKES A ROUND ACTIVE
|
||||
//
|
||||
// The one rider action that moves `Collected_By_Miler → Out_for_Delivery`.
|
||||
// Not pickup-complete, not opening the Deliveries tab, not navigation, not
|
||||
// availability — all of which have at some point been mistaken for it.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('3d · start-delivery, and what must not call it', () {
|
||||
late List<http.Request> sent;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
sent = <http.Request>[];
|
||||
MilerApi.client = MockClient((req) async {
|
||||
sent.add(req);
|
||||
return http.Response(
|
||||
jsonEncode({'success': true, 'data': {}}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
});
|
||||
tearDown(() => MilerApi.client = http.Client());
|
||||
|
||||
test('it is keyed on the consignment, with no body', () async {
|
||||
await MilerApi.startDelivery('C-4211');
|
||||
|
||||
final req = sent.single;
|
||||
expect(req.method, 'POST');
|
||||
// The **consignment**, not the booking. They are different sequences and
|
||||
// posting one where the other belongs is a 404 the rider is shown as
|
||||
// success.
|
||||
expect(
|
||||
req.url.path,
|
||||
endsWith('/miler/consignments/C-4211/start-delivery'),
|
||||
);
|
||||
expect(
|
||||
jsonDecode(req.body),
|
||||
isEmpty,
|
||||
reason:
|
||||
'the contract takes no parameters; inventing one is how a '
|
||||
'handler starts rejecting a call that used to work',
|
||||
);
|
||||
expect(req.headers['Idempotency-Key'], isNotNull);
|
||||
});
|
||||
|
||||
test('pickup-complete does not start a round by itself', () async {
|
||||
await MilerApi.pickupComplete(4211, lat: 11.0, lon: 76.9);
|
||||
|
||||
expect(
|
||||
sent.where((r) => r.url.path.contains('start-delivery')),
|
||||
isEmpty,
|
||||
reason:
|
||||
'Picked has to stay visible until the rider sets off; a '
|
||||
'pickup that starts its own round erases the rung',
|
||||
);
|
||||
expect(sent.single.url.path, contains('pickup-complete'));
|
||||
});
|
||||
|
||||
test('availability is its own call and touches no lifecycle', () async {
|
||||
await MilerApi.setAvailability('On_Pickup');
|
||||
|
||||
final req = sent.single;
|
||||
expect(req.url.path, contains('/miler/availability'));
|
||||
expect(
|
||||
req.url.path,
|
||||
isNot(contains('bookings')),
|
||||
reason: 'availability is a fact about the RIDER',
|
||||
);
|
||||
expect(req.url.path, isNot(contains('consignments')));
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 4 · ARRIVED PRECEDENCE
|
||||
//
|
||||
// The local store exists only because `reached` did not persist. It is the
|
||||
// weakest record in the app and its one rule is that it must never compete
|
||||
// with the server: it may speak where the server has said nothing further
|
||||
// along, and nowhere else.
|
||||
//
|
||||
// The rule used to be three exclusions — not complete, not cancelled, not
|
||||
// rejected — and `isWorkComplete` is line-dependent: on a milk run it means
|
||||
// *delivered or cancelled* only. So `picked`, `outForDelivery` and
|
||||
// `deliveryArrived` all passed it, and a stale local record could walk a stop
|
||||
// the hub knew was on the road back to a rung the rider left an hour ago.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('4 · the local ARRIVED record never outranks the server', () {
|
||||
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
/// The shipped rule, reached through the same public surface `_fetchQueues`
|
||||
/// uses — the status the server reported, parsed.
|
||||
bool mayShow(String serverStatus) =>
|
||||
HomepageArrivalPrecedence.localArrivalMayShow(
|
||||
stopStatusFromRaw(serverStatus),
|
||||
);
|
||||
|
||||
test('it may speak where the server has nothing further along', () {
|
||||
for (final said in const [
|
||||
'new',
|
||||
'assigned',
|
||||
'accepted',
|
||||
'arrived',
|
||||
'this is not a status the app knows',
|
||||
]) {
|
||||
expect(
|
||||
mayShow(said),
|
||||
isTrue,
|
||||
reason: '"$said" is not a rung past arrival',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('it is silent once the server has moved the stop on', () {
|
||||
// Every one of these passed the old three-exclusion test on a milk run.
|
||||
for (final said in const [
|
||||
'picked',
|
||||
'outfordelivery',
|
||||
'deliveryarrived',
|
||||
'delivered',
|
||||
'cancelled',
|
||||
'rejected',
|
||||
]) {
|
||||
expect(
|
||||
mayShow(said),
|
||||
isFalse,
|
||||
reason:
|
||||
'"$said" is the server\'s own answer — a local record that '
|
||||
'overwrote it would walk the stop backwards',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the rung the map path writes on arrival is ARRIVED, not ACTIVE', () {
|
||||
// ── Where "Picked" became "Active" ──
|
||||
//
|
||||
// `_startPickupNavigation` wrote `orderstatus = 'active'` onto the row.
|
||||
// Two separate facts were going into one field: *which stop is live*,
|
||||
// which `_activePickupOrderId` already carries and is what the LIVE mark
|
||||
// reads, and *how far up the pickup ladder this stop is*.
|
||||
//
|
||||
// `active` was never a server state either. The provider maps it to
|
||||
// `setAvailability('On_Pickup')` — a fact about the **rider** — because
|
||||
// there is no rider endpoint that sets a booking Active;
|
||||
// `Pickup_Scheduled` is system-set. So the word was app-generated and it
|
||||
// outranked the real rung on every screen reading `orderstatus`.
|
||||
//
|
||||
// Pinned on the resolver, not on any widget's text.
|
||||
expect(
|
||||
stopStatusFromRaw('active'),
|
||||
StopStatus.active,
|
||||
reason: 'active parses as its own thing and is not a pickup rung',
|
||||
);
|
||||
expect(stopStatusFromRaw('active'), isNot(StopStatus.picked));
|
||||
expect(stopStatusFromRaw('active').isPicked, isFalse);
|
||||
expect(
|
||||
stopStatusFromRaw('active').isFinishedPickup,
|
||||
isFalse,
|
||||
reason: 'an active stop is not a collected one',
|
||||
);
|
||||
});
|
||||
|
||||
test('the server\'s own arrival word resolves to the arrived rung', () {
|
||||
for (final said in const [
|
||||
'Arrived_At_Pickup',
|
||||
'arrived_at_pickup',
|
||||
'ARRIVEDATPICKUP',
|
||||
'arrived',
|
||||
]) {
|
||||
expect(
|
||||
stopStatusFromRaw(said),
|
||||
StopStatus.arrived,
|
||||
reason: '"$said" must land on the rung the rider was shown',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('picked and active are distinct rungs, and stay distinct', () {
|
||||
// The explicit regression: `Picked != Active`.
|
||||
expect(stopStatusFromRaw('picked'), StopStatus.picked);
|
||||
expect(stopStatusFromRaw('picked'), isNot(stopStatusFromRaw('active')));
|
||||
expect(stopStatusFromRaw('picked').isPicked, isTrue);
|
||||
});
|
||||
|
||||
test('and it is silent on a parcel route too', () {
|
||||
// `isWorkComplete` differs by line, which is exactly what made the old
|
||||
// blacklist leak. The rule must not depend on which line is running.
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
expect(mayShow('picked'), isFalse);
|
||||
expect(mayShow('accepted'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
109
test/no_hub_in_rider_copy_test.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE RIDER IS NEVER TOLD ABOUT A HUB
|
||||
///
|
||||
/// "Hub" is an internal word. It names a building in the logistics network and
|
||||
/// a row in the consignment state machine, and neither is something a rider has
|
||||
/// any use for on a screen.
|
||||
///
|
||||
/// ── The failure that made this a rule ──
|
||||
///
|
||||
/// A meal-run rider slid **Start ride** on a stop 8 km from the kitchen and was
|
||||
/// answered: *"This parcel is with the hub — it will be delivered from there,
|
||||
/// not by you."* Every part of that is wrong for him. There is no hub on a
|
||||
/// hyperlocal round; the food was in his box; and the sentence told him to give
|
||||
/// up on a stop that was his. He could do nothing with it, because it described
|
||||
/// a system he does not work in.
|
||||
///
|
||||
/// The state behind it (`Inwarded_at_Hub` and friends) cannot occur on that
|
||||
/// line at all — see `_handoffHoldIsPossible` — so the message was a bad read
|
||||
/// wearing confident words. That half is fixed in code. This is the other half:
|
||||
/// the vocabulary itself does not belong in front of a rider, on any line.
|
||||
///
|
||||
/// ── What is still allowed ──
|
||||
///
|
||||
/// `debugPrint` and `ApiConfig.logGap` are engineering output — they are read
|
||||
/// in a console by whoever is chasing a backend gap, and "hub" is the right
|
||||
/// word there. Wire values (`inwarded_at_hub`) are the backend's own spelling
|
||||
/// and must match it exactly. Identifiers are internal.
|
||||
///
|
||||
/// Everything else — anything a rider can read — says *your office*, or names
|
||||
/// the destination *base*.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
test('no rider-facing string says "hub"', () {
|
||||
// A comment, or the backend's own wire spelling.
|
||||
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');
|
||||
}
|
||||
|
||||
/// True while the scan is inside a `debugPrint(...)` / `logGap(...)` call.
|
||||
///
|
||||
/// Tracked across lines rather than matched on one, because these calls
|
||||
/// wrap: the string that names the hub sits three lines below the
|
||||
/// `debugPrint(` that makes it engineering output rather than copy. A
|
||||
/// same-line check called those a violation and would have pushed the next
|
||||
/// person to reword a console log.
|
||||
var diagDepth = 0;
|
||||
bool inDiagnostic(String line) {
|
||||
final opensHere =
|
||||
diagDepth == 0 &&
|
||||
(line.contains('debugPrint(') || line.contains('logGap('));
|
||||
if (diagDepth > 0 || opensHere) {
|
||||
// Count only from the call itself on the line that opens it.
|
||||
final from = opensHere
|
||||
? line.indexOf(RegExp(r'(debugPrint|logGap)\('))
|
||||
: 0;
|
||||
for (final c in line.substring(from).split('')) {
|
||||
if (c == '(') diagDepth++;
|
||||
if (c == ')') diagDepth--;
|
||||
}
|
||||
if (diagDepth < 0) diagDepth = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// "hub" as a WORD inside a quoted literal — `hubid`, `_hubRow` and
|
||||
// `endsAtHub` are identifiers and none of them reach a screen.
|
||||
final quoted = RegExp(r"""(['"])((?:\\.|(?!\1).)*)\1""");
|
||||
final word = RegExp(r'\bhubs?\b', caseSensitive: false);
|
||||
|
||||
final offenders = <String>[];
|
||||
for (final e in Directory('lib').listSync(recursive: true)) {
|
||||
if (e is! File || !e.path.endsWith('.dart')) continue;
|
||||
// The Xpress reference implementation is not this app's UI, and no live
|
||||
// tenant can route into it. See `TenantController.deliveryTenantIds`.
|
||||
if (e.path.contains('/xpress/')) continue;
|
||||
|
||||
final lines = e.readAsLinesSync();
|
||||
diagDepth = 0;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (inDiagnostic(lines[i])) continue;
|
||||
if (exemptLine(lines[i])) continue;
|
||||
for (final m in quoted.allMatches(lines[i])) {
|
||||
final literal = m.group(2) ?? '';
|
||||
if (word.hasMatch(literal)) {
|
||||
offenders.add('${e.path}:${i + 1} "$literal"');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
isEmpty,
|
||||
reason:
|
||||
'These strings can reach a rider and name an internal concept.\n'
|
||||
'Use "your office" for the people, "base" for the place:\n'
|
||||
' ${offenders.join('\n ')}',
|
||||
);
|
||||
});
|
||||
}
|
||||
430
test/order_detail_leg_test.dart
Normal file
@@ -0,0 +1,430 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart';
|
||||
import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip_card.dart';
|
||||
import 'package:miler/data/bag_manifest.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHICH LEG A SHEET IS ABOUT, AND HOW TALL IT IS
|
||||
///
|
||||
/// ── Two surfaces, two subjects ──
|
||||
///
|
||||
/// Home's kitchen card and the order rows inside it answer different
|
||||
/// questions, and both used to answer the first one.
|
||||
///
|
||||
/// Every order under a counter shares that counter's address, so a sheet
|
||||
/// opened from an order row that pinned the counter drew the identical map
|
||||
/// once per order and said nothing about the order the rider actually tapped.
|
||||
/// What distinguishes one order from the next is the **door it is going to**.
|
||||
///
|
||||
/// The place is not left without a map — it has a better one, on the preview
|
||||
/// behind the card's third utility, which draws the whole leg from where the
|
||||
/// rider is standing to the counter with the manifest waiting on it.
|
||||
///
|
||||
/// ── Why the payload's own `type` could not decide it ──
|
||||
///
|
||||
/// `ApiConfig.pickupFromBooking` stamps `type: pickup` on every row it builds,
|
||||
/// because a v1 booking *is* a first-mile pickup and the backend has no
|
||||
/// per-stop type to send. So the sheet's `_isDropLeg` — which reads that type
|
||||
/// through `MilkRun.workingKind` — answered "pickup" for every uncollected
|
||||
/// meal order, and the customer's sheet showed the kitchen. The caller knows
|
||||
/// what it opened the sheet for; the payload does not. Hence
|
||||
/// [StopDetailSheet.ofOrder].
|
||||
///
|
||||
/// ── And the sheet reserved space it did not use ──
|
||||
///
|
||||
/// It was a `DraggableScrollableSheet` at `initialChildSize: 0.78`, which
|
||||
/// claims 78% of the screen whether or not it has 78% of a screen to say. A
|
||||
/// meal order ended at its CONTACT tile with a third of the sheet left over as
|
||||
/// blank paper. It measures its content now, capped at 86%.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
tearDown(() {
|
||||
Get.reset();
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
});
|
||||
|
||||
const kitchenStreet = '9 Kitchen Street, RS Puram, Coimbatore 641002';
|
||||
const customerStreet = '12, SNS Colony, Peelamedu, Coimbatore 641004';
|
||||
|
||||
/// An order exactly as the adapter hands it over — including the `pickup`
|
||||
/// type it stamps on every row, which is the shape that broke.
|
||||
Map<String, dynamic> order({bool withDrop = true}) => <String, dynamic>{
|
||||
'orderid': 'D1',
|
||||
'pickupid': 1001,
|
||||
'step': 1,
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'sourceid': 'K1',
|
||||
'pickupcustomer': 'Joe Mathew',
|
||||
'pickupcontactno': '9787698259',
|
||||
'pickupaddress': kitchenStreet,
|
||||
'pickuplat': 11.0168,
|
||||
'pickuplon': 76.9558,
|
||||
if (withDrop) 'dropaddress': customerStreet,
|
||||
if (withDrop) 'droplat': 11.0208,
|
||||
if (withDrop) 'droplon': 76.9598,
|
||||
'orderstatus': 'accepted',
|
||||
'type': 'pickup',
|
||||
'baglabel': 'Bag 1',
|
||||
'Quantity': 1,
|
||||
};
|
||||
|
||||
Future<void> openSheet(
|
||||
WidgetTester tester,
|
||||
Future<void> Function(BuildContext) open, {
|
||||
double height = 844,
|
||||
}) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
tester.view.physicalSize = Size(390 * 3, height * 3);
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
// Tear down whatever was mounted before, routes included. Without this a
|
||||
// test that opens the sheet twice measures the *first* one both times —
|
||||
// the old route is still on the navigator, and `.first` finds it.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
late BuildContext ctx;
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (c) {
|
||||
ctx = c;
|
||||
return const SizedBox.expand();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(open(ctx));
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
group('an order points at its own door', () {
|
||||
testWidgets('opened from an order row, the sheet shows the delivery', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(
|
||||
tester,
|
||||
(c) => StopDetailSheet.show(
|
||||
c,
|
||||
stop: order(),
|
||||
stopNumber: 1,
|
||||
ofOrder: true,
|
||||
riderLat: 11.0168,
|
||||
riderLng: 76.9558,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('DELIVER TO'), findsOneWidget);
|
||||
expect(find.text(customerStreet), findsOneWidget);
|
||||
expect(
|
||||
find.text(kitchenStreet),
|
||||
findsNothing,
|
||||
reason: 'the counter is the card\'s subject, not the order\'s',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('opened for a place, the sheet still shows the place', (
|
||||
tester,
|
||||
) async {
|
||||
// A flat group's header IS a location — one stop, one counter — so
|
||||
// nothing about that case changes.
|
||||
await openSheet(
|
||||
tester,
|
||||
(c) => StopDetailSheet.show(c, stop: order(), stopNumber: 1),
|
||||
);
|
||||
|
||||
expect(find.text('PICKUP LOCATION'), findsOneWidget);
|
||||
expect(find.text(kitchenStreet), findsOneWidget);
|
||||
expect(find.text(customerStreet), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a row with no drop falls back without lying about it', (
|
||||
tester,
|
||||
) async {
|
||||
// The half-populated payload. What must never happen is `DELIVER TO`
|
||||
// printed over a kitchen's street: the eyebrow, the street and the pin
|
||||
// are resolved together, so falling back to the pickup means it stops
|
||||
// calling itself a drop.
|
||||
await openSheet(
|
||||
tester,
|
||||
(c) => StopDetailSheet.show(
|
||||
c,
|
||||
stop: order(withDrop: false),
|
||||
stopNumber: 1,
|
||||
ofOrder: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('DELIVER TO'), findsNothing);
|
||||
expect(find.text('PICKUP LOCATION'), findsOneWidget);
|
||||
expect(find.text(kitchenStreet), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('the place keeps its own leg, on the preview', () {
|
||||
testWidgets('the preview is where the counter is drawn', (tester) async {
|
||||
// The pickup did not disappear when the order sheet stopped showing it —
|
||||
// it moved to the surface that is actually about the place, behind the
|
||||
// card's third utility, where it comes with the manifest.
|
||||
await openSheet(
|
||||
tester,
|
||||
(c) => PickupPreviewSheet.show(
|
||||
c,
|
||||
placeName: 'Vidhya Kitchen',
|
||||
lines: [
|
||||
BagLine(
|
||||
stop: order(),
|
||||
orderId: 'D1',
|
||||
customer: 'Joe Mathew',
|
||||
bag: 'Bag 1',
|
||||
),
|
||||
BagLine(
|
||||
stop: order(),
|
||||
orderId: 'D2',
|
||||
customer: 'Priya Nair',
|
||||
bag: 'Bag 2',
|
||||
),
|
||||
],
|
||||
meters: 3100,
|
||||
ride: const Duration(minutes: 9),
|
||||
riderLat: 11.0168,
|
||||
riderLng: 76.9558,
|
||||
onNavigate: () {},
|
||||
onCall: () {},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Vidhya Kitchen'), findsOneWidget);
|
||||
expect(find.text('Joe Mathew'), findsOneWidget);
|
||||
expect(find.text('Priya Nair'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('the sheet is as tall as what is on it', () {
|
||||
Finder scaffold() => find.byType(MilerSheetScaffold).first;
|
||||
|
||||
Finder viewport() => find
|
||||
.descendant(
|
||||
of: scaffold(),
|
||||
matching: find.byType(SingleChildScrollView),
|
||||
)
|
||||
.first;
|
||||
|
||||
Finder content() =>
|
||||
find.descendant(of: viewport(), matching: find.byType(Column)).first;
|
||||
|
||||
testWidgets('a short order reserves no blank paper below it', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(
|
||||
tester,
|
||||
(c) => StopDetailSheet.show(
|
||||
c,
|
||||
stop: order(),
|
||||
stopNumber: 1,
|
||||
ofOrder: true,
|
||||
riderLat: 11.0168,
|
||||
riderLng: 76.9558,
|
||||
),
|
||||
);
|
||||
|
||||
// ── The measurement that actually names the complaint ──
|
||||
//
|
||||
// "Shorter than before" is the wrong assertion: a content-sized sheet is
|
||||
// sometimes *taller* than the fixed 78% it replaced, because it stopped
|
||||
// hiding the last tile behind a scroll. What was wrong was the gap — the
|
||||
// list's viewport was 78% of the screen while the rows inside it ended a
|
||||
// third of the way up it, and the difference was blank paper.
|
||||
//
|
||||
// So: the scroll viewport is exactly as tall as what is inside it. That
|
||||
// is true of a content-sized sheet at any height, and false of a fixed
|
||||
// one at every height short of full.
|
||||
expect(
|
||||
tester.getSize(viewport()).height,
|
||||
tester.getSize(content()).height,
|
||||
reason: 'a viewport taller than its content IS the blank space',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the sheet grows and shrinks with what is on it', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(
|
||||
tester,
|
||||
(c) => StopDetailSheet.show(c, stop: order(), stopNumber: 1),
|
||||
);
|
||||
final plain = tester.getSize(scaffold()).height;
|
||||
|
||||
await openSheet(
|
||||
tester,
|
||||
(c) => StopDetailSheet.show(
|
||||
c,
|
||||
stop: order()..['notes'] = 'Gate code 4411, ring the watchman.',
|
||||
stopNumber: 1,
|
||||
),
|
||||
);
|
||||
final withNote = tester.getSize(scaffold()).height;
|
||||
|
||||
expect(
|
||||
withNote,
|
||||
greaterThan(plain),
|
||||
reason: 'a fixed 0.78 would have reported the same number twice',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a long order is capped, and scrolls rather than overflows', (
|
||||
tester,
|
||||
) async {
|
||||
final long = order()
|
||||
..['pickupcustomer'] = 'Venkataraman Subramanian Balasubramanian'
|
||||
..['notes'] =
|
||||
'Gate code 4411, leave with security if unreachable. Second '
|
||||
'building on the left after the temple, ask for the watchman at '
|
||||
'the side entrance, the main gate is locked after nine, and the '
|
||||
'lift stops running at eleven so use the back stairs.'
|
||||
..['dropaddress'] =
|
||||
'Flat 14B, Second Floor, Sri Venkateswara Residency, 12 SNS '
|
||||
'Colony Extension, Behind the Water Tank, Peelamedu, Coimbatore, '
|
||||
'Tamil Nadu 641004';
|
||||
|
||||
await openSheet(
|
||||
tester,
|
||||
(c) => StopDetailSheet.show(
|
||||
c,
|
||||
stop: long,
|
||||
stopNumber: 7,
|
||||
ofOrder: true,
|
||||
bag: 'Bag 1',
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
// The ceiling keeps the scrim — and the route behind it — visible at the
|
||||
// top of every phone.
|
||||
expect(tester.getSize(scaffold()).height, lessThanOrEqualTo(844 * 0.87));
|
||||
// And the overflow goes to the scroll view rather than off the sheet.
|
||||
expect(
|
||||
tester.getSize(content()).height,
|
||||
greaterThan(tester.getSize(viewport()).height),
|
||||
);
|
||||
expect(find.text('DELIVER TO'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('the timeline routes the two taps to different handlers', () {
|
||||
Future<void> pumpCard(
|
||||
WidgetTester tester, {
|
||||
required List<Map<String, dynamic>> stops,
|
||||
void Function(Map<String, dynamic>)? onStopTap,
|
||||
void Function(Map<String, dynamic>)? onOrderTap,
|
||||
}) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
tester.view.physicalSize = const Size(390 * 3, 1400 * 3);
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: TripCard(
|
||||
trip: Trip.fromStops(id: 'T1', stops: stops),
|
||||
onStopTap: onStopTap,
|
||||
onOrderTap: onOrderTap,
|
||||
onToggleSelect: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The `Bag n ›` block — the one control on an order row that opens its
|
||||
/// sheet, found by the promise it makes to a screen reader.
|
||||
Finder openBlock() => find.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.hint == 'Opens order details',
|
||||
);
|
||||
|
||||
Map<String, dynamic> grouped(String id, String who) => <String, dynamic>{
|
||||
'orderid': id,
|
||||
'pickupid': int.parse(id),
|
||||
'step': int.parse(id) - 1000,
|
||||
'kitchenname': 'Vidhya Kitchen',
|
||||
'sourceid': 'K1',
|
||||
'pickupcustomer': who,
|
||||
'pickupaddress': kitchenStreet,
|
||||
'dropaddress': customerStreet,
|
||||
'orderstatus': 'accepted',
|
||||
'type': 'pickup',
|
||||
'pickuplat': 11.01,
|
||||
'pickuplon': 76.95,
|
||||
};
|
||||
|
||||
testWidgets('tapping an order row calls onOrderTap, not onStopTap', (
|
||||
tester,
|
||||
) async {
|
||||
final orders = <Map<String, dynamic>>[];
|
||||
final places = <Map<String, dynamic>>[];
|
||||
|
||||
await pumpCard(
|
||||
tester,
|
||||
stops: [grouped('1001', 'Joe Mathew'), grouped('1002', 'Priya Nair')],
|
||||
onStopTap: places.add,
|
||||
onOrderTap: orders.add,
|
||||
);
|
||||
|
||||
// The group is open by default — it is the one the rider is working.
|
||||
expect(find.text('Joe Mathew'), findsOneWidget);
|
||||
// The name is the *selection* target; the way into an order's sheet is
|
||||
// the `Bag 1 ›` block at the end of its row. One gesture, one meaning —
|
||||
// see the note on `_OrderOpenBlock`.
|
||||
await tester.tap(openBlock().first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(orders.length, 1, reason: 'an order row is about one order');
|
||||
expect(orders.single['orderid'], '1001');
|
||||
expect(places, isEmpty, reason: 'the place has its own handler');
|
||||
});
|
||||
|
||||
testWidgets('onOrderTap falls back to onStopTap when unset', (
|
||||
tester,
|
||||
) async {
|
||||
// A caller that does not care keeps the old single-callback behaviour,
|
||||
// so nothing outside Home has to know this split exists.
|
||||
final places = <Map<String, dynamic>>[];
|
||||
|
||||
await pumpCard(
|
||||
tester,
|
||||
stops: [grouped('1001', 'Joe Mathew'), grouped('1002', 'Priya Nair')],
|
||||
onStopTap: places.add,
|
||||
);
|
||||
|
||||
await tester.tap(openBlock().first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(places.length, 1);
|
||||
expect(places.single['orderid'], '1001');
|
||||
});
|
||||
});
|
||||
}
|
||||
309
test/picked_does_not_release_test.dart
Normal file
@@ -0,0 +1,309 @@
|
||||
import 'dart:io';
|
||||
|
||||
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/api_status.dart';
|
||||
import 'package:miler/data/miler_api.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/service_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// PICKED IS NOT ACTIVE, AND ONLY THE SLIDER MOVES IT
|
||||
///
|
||||
/// The production lifecycle the hub, the console and this app must all agree
|
||||
/// on:
|
||||
///
|
||||
/// ```
|
||||
/// Pending → Accepted → Arrived → Picked → Active → Delivered
|
||||
/// ▲ ▲
|
||||
/// pickup-complete start-delivery
|
||||
/// Collected_By_Miler Out_for_Delivery
|
||||
/// ```
|
||||
///
|
||||
/// Two separate defects collapsed **Picked** into **Active**, and fixing either
|
||||
/// alone leaves the symptom:
|
||||
///
|
||||
/// 1. **Backend.** With `MILER_COLLECTED_STATE_ENABLED` off, `pickup-complete`
|
||||
/// writes `Out_for_Delivery` itself. Not reachable from this app and not
|
||||
/// testable here — it is the deployment's behaviour. What IS pinned below
|
||||
/// is that the app never claims Picked over such a response.
|
||||
///
|
||||
/// 2. **App.** `MyPickups.startPickup` released the consignment when the rider
|
||||
/// merely *tapped a card* — one tap after Picked, before any slider. That
|
||||
/// is invisible while the flag is off (the pivot has already released, so
|
||||
/// the release costs no request) and becomes the whole bug the moment the
|
||||
/// flag is flipped. Removed; pinned here.
|
||||
///
|
||||
/// These tests are the reason the flag flip is safe. Read them before changing
|
||||
/// any call site that reaches `MilerApi.startDelivery`.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 1 · PICKED DOES NOT CALL START-DELIVERY
|
||||
//
|
||||
// Enforced at the source, because this is a statement about *call sites*
|
||||
// and no behavioural test can prove the absence of one. Every route to
|
||||
// `MilerApi.startDelivery` is enumerated; a new one fails this test and has
|
||||
// to be justified here rather than discovered in production.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('only the rider setting off releases a consignment', () {
|
||||
/// Every `lib/` file that mentions a symbol, with its line numbers.
|
||||
Map<String, List<int>> sitesOf(String symbol) {
|
||||
final out = <String, List<int>>{};
|
||||
for (final e in Directory('lib').listSync(recursive: true)) {
|
||||
if (e is! File || !e.path.endsWith('.dart')) continue;
|
||||
final lines = e.readAsLinesSync();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final l = lines[i];
|
||||
// Prose in a doc comment is not a call.
|
||||
if (l.trimLeft().startsWith('//') || l.trimLeft().startsWith('///')) {
|
||||
continue;
|
||||
}
|
||||
if (l.contains(symbol)) (out[e.path] ??= []).add(i + 1);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('`MilerApi.startDelivery` has exactly one caller', () {
|
||||
final sites = sitesOf('MilerApi.startDelivery(');
|
||||
expect(
|
||||
sites.keys.toSet(),
|
||||
{'lib/views/Dashboard/pickups/delivery_actions.dart'},
|
||||
reason:
|
||||
'the release is `releaseForDelivery`\'s alone. A second caller is '
|
||||
'a second lifecycle, and the console sees both.',
|
||||
);
|
||||
});
|
||||
|
||||
test('`releaseForDelivery` is reached only from setting off or the door', () {
|
||||
final sites = sitesOf('releaseForDelivery(')
|
||||
..remove('lib/views/Dashboard/pickups/delivery_actions.dart');
|
||||
expect(
|
||||
sites.keys.toSet(),
|
||||
{
|
||||
// The Start-ride slider on the stop's own map.
|
||||
'lib/views/Dashboard/pickups/map.dart',
|
||||
// The Start-round bar, and nothing else on that screen. This file
|
||||
// held the defect: `startPickup` called `startRound` on card open.
|
||||
'lib/views/Dashboard/pickups/pickups.dart',
|
||||
},
|
||||
reason: 'a new caller must be a deliberate "the rider has set off"',
|
||||
);
|
||||
});
|
||||
|
||||
test('tapping a card does not release — the defect, pinned', () {
|
||||
// `startPickup` opens a stop's map. It must contain no release: the
|
||||
// rider tapping a card to read an address has not set off, and
|
||||
// `navigatesToCustomer` is true from the instant Picked succeeds.
|
||||
final src = File(
|
||||
'lib/views/Dashboard/pickups/pickups.dart',
|
||||
).readAsStringSync();
|
||||
final start = src.indexOf('Future<void> startPickup(');
|
||||
expect(start, greaterThan(0), reason: 'startPickup was renamed');
|
||||
final end = src.indexOf('Future<void> resumePickup(', start);
|
||||
expect(end, greaterThan(start));
|
||||
|
||||
final body = src
|
||||
.substring(start, end)
|
||||
.split('\n')
|
||||
.where((l) => !l.trimLeft().startsWith('//'))
|
||||
.join('\n');
|
||||
|
||||
expect(
|
||||
body.contains('startRound('),
|
||||
isFalse,
|
||||
reason:
|
||||
'opening a stop released it, so the console read Active one tap '
|
||||
'after Picked and before any slider was touched',
|
||||
);
|
||||
expect(body.contains('releaseForDelivery('), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 2 · COLLECTED_BY_MILER IS PICKED, AND STAYS PICKED
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('Collected_By_Miler reads as Picked', () {
|
||||
test('the pivot response is read as collected, not as released', () {
|
||||
final t = MilerLifecycle.pickupComplete(
|
||||
ApiResult(
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: const {
|
||||
'bookingid': 78,
|
||||
'consignmentid': 34,
|
||||
'status': 'Converted_To_Consignment',
|
||||
'consignmentstatus': 'Collected_By_Miler',
|
||||
'next_action': 'start_delivery',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(t.isConfirmed, isTrue);
|
||||
expect(t.bookingStatus, BookingStatus.convertedToConsignment);
|
||||
expect(t.consignmentState, ConsignmentState.collectedByMiler);
|
||||
expect(t.consignmentId, '34');
|
||||
expect(t.awaitsStartDelivery, isTrue);
|
||||
expect(
|
||||
t.isCompatibilityMode,
|
||||
isFalse,
|
||||
reason: 'the flag is ON in this response — the release is still to come',
|
||||
);
|
||||
});
|
||||
|
||||
test('a collected stop shows the Picked rung', () {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
final stop = <String, dynamic>{
|
||||
'orderid': 'A1',
|
||||
'orderstatus': ApiConfig.legacyStatusFromConsignment(
|
||||
'Collected_By_Miler',
|
||||
),
|
||||
'consignmentstatus': 'Collected_By_Miler',
|
||||
};
|
||||
expect(stop['orderstatus'], 'picked', reason: 'adapter contract');
|
||||
expect(
|
||||
MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}),
|
||||
StopStatus.picked,
|
||||
);
|
||||
});
|
||||
|
||||
test('and it still needs releasing — the bar has work to offer', () {
|
||||
expect(
|
||||
consignmentStateFromRaw('Collected_By_Miler').needsRelease,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
consignmentStateFromRaw('Collected_By_Miler').isDeliverable,
|
||||
isFalse,
|
||||
reason: '`deliver` accepts Out_for_Delivery only',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 3 · START RIDE IS WHAT MAKES IT ACTIVE
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('Start ride moves collected → out for delivery', () {
|
||||
test('the released state is the Active rung, and only via the local set', () {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
final stop = <String, dynamic>{
|
||||
'orderid': 'A1',
|
||||
'orderstatus': ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'),
|
||||
'consignmentstatus': 'Out_for_Delivery',
|
||||
};
|
||||
|
||||
// Before the slider: the rider's own record does not carry it, so the
|
||||
// rung is Picked even though the consignment is released.
|
||||
expect(
|
||||
MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}),
|
||||
StopStatus.picked,
|
||||
);
|
||||
|
||||
// After the slider: `startRound` writes the id, and only then.
|
||||
expect(
|
||||
MilkRun.stageOf(
|
||||
stop,
|
||||
acceptedIds: const {},
|
||||
collectedIds: const {'A1'},
|
||||
outForDeliveryIds: const {'A1'},
|
||||
),
|
||||
StopStatus.outForDelivery,
|
||||
);
|
||||
});
|
||||
|
||||
test('a released consignment is deliverable and needs no second release', () {
|
||||
final s = consignmentStateFromRaw('Out_for_Delivery');
|
||||
expect(s.isDeliverable, isTrue);
|
||||
expect(s.needsRelease, isFalse);
|
||||
expect(ConsignmentGate.gateFor(s), DeliverGate.deliverable);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// 4 · A STALE OR EARLY ROW CANNOT COLLAPSE PICKED INTO ACTIVE
|
||||
//
|
||||
// `GET /miler/bookings` is a poll behind the rider by design. With the flag
|
||||
// OFF the pivot releases the consignment itself, so the very next poll
|
||||
// reports `Out_for_Delivery` for a bag still on the counter. The rider's
|
||||
// card must not jump to Active on that — his own set is what says he set
|
||||
// off. This is the app half of the compatibility-mode disagreement, and it
|
||||
// is display only: nothing here is sent to the server.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
group('polling cannot start the round for the rider', () {
|
||||
test('compatibility mode is reported, never silently adopted', () {
|
||||
final t = MilerLifecycle.pickupComplete(
|
||||
ApiResult(
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: const {
|
||||
'bookingid': 78,
|
||||
'consignmentid': 34,
|
||||
'consignmentstatus': 'Out_for_Delivery',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(t.isConfirmed, isTrue);
|
||||
expect(t.consignmentState, ConsignmentState.outForDelivery);
|
||||
expect(
|
||||
t.isCompatibilityMode,
|
||||
isTrue,
|
||||
reason:
|
||||
'the flag is OFF on this deployment — the pivot released it, and '
|
||||
'the console showing Active is the SERVER\'s state, not a client '
|
||||
'bug. Reported so the trace names the backend.',
|
||||
);
|
||||
expect(t.awaitsStartDelivery, isFalse);
|
||||
});
|
||||
|
||||
test('an early Out_for_Delivery row still draws Picked', () {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
// Exactly what the next poll returns in compatibility mode.
|
||||
final stop = <String, dynamic>{
|
||||
'orderid': 'A1',
|
||||
'orderstatus': 'outfordelivery',
|
||||
'consignmentstatus': 'Out_for_Delivery',
|
||||
};
|
||||
expect(
|
||||
MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}),
|
||||
StopStatus.picked,
|
||||
reason:
|
||||
'the rider has not slid Start ride; the row is the consignment\'s '
|
||||
'state, not his',
|
||||
);
|
||||
});
|
||||
|
||||
test('but a genuinely finished stop is never dragged backwards', () {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
for (final raw in const ['Delivered', 'delivered']) {
|
||||
// Built the way `ApiConfig.pickupFromBooking` builds a real row: the
|
||||
// consignment's word is translated into `orderstatus`, which is what
|
||||
// `stopStatusOf` reads. Setting `consignmentstatus` alone would be a
|
||||
// row this app never sees, and would prove nothing about the ladder.
|
||||
final stop = <String, dynamic>{
|
||||
'orderid': 'A1',
|
||||
'orderstatus': ApiConfig.legacyStatusFromConsignment(raw),
|
||||
'consignmentstatus': raw,
|
||||
};
|
||||
expect(stop['orderstatus'], 'delivered', reason: 'adapter contract');
|
||||
expect(
|
||||
MilkRun.stageOf(
|
||||
stop,
|
||||
acceptedIds: const {},
|
||||
collectedIds: const {'A1'},
|
||||
),
|
||||
StopStatus.delivered,
|
||||
reason: 'precedence must only ever hold a stop back, never a close',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -196,6 +196,38 @@ void main() {
|
||||
);
|
||||
expect(deliveries.map(MilkRun.idOf), ['A1']);
|
||||
});
|
||||
|
||||
test('a delivered stop leaves the count, not just the list', () {
|
||||
// ── The two screens disagreed because they asked different questions ──
|
||||
//
|
||||
// Both call `deliveryQueue`, which is supposed to be what stops that
|
||||
// happening. It cannot, when they pass different arguments: Deliveries
|
||||
// passed `closedIds`, Home's pill did not. The rider finished a drop,
|
||||
// watched the tab go 22 → 21, and came back to Home still holding 22.
|
||||
//
|
||||
// Worse on Home than a plain lag, because Home *stamps* a closed stop
|
||||
// `picked` so its row cannot flicker back to LIVE while the queue
|
||||
// catches up — and `picked` is squarely inside the delivery domain. The
|
||||
// pill was counting the stamp that existed to hide the stop.
|
||||
final day = [
|
||||
order('B1', status: 'picked'),
|
||||
order('B2', status: 'picked'),
|
||||
];
|
||||
|
||||
expect(WorkBoundary.deliveryQueue(day).length, 2);
|
||||
expect(
|
||||
WorkBoundary.deliveryQueue(day, closedIds: const {'B1'}).length,
|
||||
1,
|
||||
reason: 'the finished drop is off both the list and the count',
|
||||
);
|
||||
|
||||
// And it does not reappear on Home instead — closed is closed, not
|
||||
// handed back across the boundary.
|
||||
expect(
|
||||
WorkBoundary.pickupQueue(day, closedIds: const {'B1'}).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('logistics — the boundary is acceptance, and that is unchanged', () {
|
||||
|
||||
117
test/pickup_locations_test.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/data/pickup_locations.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE PICKUP IS NAMED BY THE TENANT'S LOCATION LIST
|
||||
///
|
||||
/// The route card's heading — the biggest type on Home — read `Sudharsan`,
|
||||
/// a contact person, above a distance and an ETA to a place that name does not
|
||||
/// identify. It came from the booking row's `sourcename`, which the backend
|
||||
/// fills from `providercompany` / `providerlocation`.
|
||||
///
|
||||
/// The app cannot tell a person from a shop by looking at the string, so the
|
||||
/// name is **joined** instead: the booking carries the location id, and
|
||||
/// `GET /admin/tenants/:id/locations` carries the names.
|
||||
///
|
||||
/// The half that matters most is the fallback. That route is under `/admin`,
|
||||
/// and whether a rider's token is admitted to it is the backend's call — so
|
||||
/// every one of these proves the app is no worse off than before when the
|
||||
/// table is empty.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
setUp(PickupLocations.reset);
|
||||
tearDown(PickupLocations.reset);
|
||||
|
||||
Map<String, dynamic> stop({
|
||||
Object? locationId,
|
||||
String sourceName = '',
|
||||
}) => {
|
||||
'orderid': 'A1',
|
||||
if (locationId != null) 'pickuplocationid': locationId,
|
||||
'sourcename': sourceName,
|
||||
};
|
||||
|
||||
group('with the tenant list loaded', () {
|
||||
test('the location name replaces the person on the row', () {
|
||||
PickupLocations.seed({'7': 'Sri Balaji Stores'});
|
||||
expect(
|
||||
MilkRun.sourceNameOf(stop(locationId: 7, sourceName: 'Sudharsan')),
|
||||
'Sri Balaji Stores',
|
||||
);
|
||||
});
|
||||
|
||||
test('it also fills a row that names nothing at all', () {
|
||||
PickupLocations.seed({'7': 'Gandhipuram Counter'});
|
||||
expect(
|
||||
MilkRun.sourceNameOf(stop(locationId: 7)),
|
||||
'Gandhipuram Counter',
|
||||
);
|
||||
});
|
||||
|
||||
test('`sourceid` and `kitchenid` join on the same table', () {
|
||||
PickupLocations.seed({'9': 'Peelamedu Kitchen'});
|
||||
expect(
|
||||
MilkRun.sourceNameOf({'orderid': 'A1', 'sourceid': 9}),
|
||||
'Peelamedu Kitchen',
|
||||
);
|
||||
expect(
|
||||
MilkRun.sourceNameOf({'orderid': 'A1', 'kitchenid': '9'}),
|
||||
'Peelamedu Kitchen',
|
||||
);
|
||||
});
|
||||
|
||||
test('an id the tenant list does not hold falls back to the row', () {
|
||||
PickupLocations.seed({'7': 'Sri Balaji Stores'});
|
||||
expect(
|
||||
MilkRun.sourceNameOf(stop(locationId: 99, sourceName: 'Sudharsan')),
|
||||
'Sudharsan',
|
||||
reason: 'a partial table must not blank out the names it does have',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('with no tenant list — the admin route refused, or has not answered', () {
|
||||
test('the row is read exactly as it was before', () {
|
||||
expect(
|
||||
MilkRun.sourceNameOf(stop(locationId: 7, sourceName: 'Sudharsan')),
|
||||
'Sudharsan',
|
||||
);
|
||||
expect(MilkRun.sourceNameOf(stop(locationId: 7)), '');
|
||||
});
|
||||
|
||||
test('and grouping still works off the id', () {
|
||||
// The group key was never the name — it is the counter's id — so a
|
||||
// missing table cannot merge two kitchens into one card.
|
||||
final a = stop(locationId: 7);
|
||||
final b = stop(locationId: 8);
|
||||
expect(MilkRun.sameSource(a, a), isTrue);
|
||||
expect(MilkRun.sameSource(a, b), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('the lookup itself', () {
|
||||
test('an absent, empty or zero id is never a name', () {
|
||||
PickupLocations.seed({'7': 'Sri Balaji Stores', '0': 'Nowhere'});
|
||||
expect(PickupLocations.nameFor(null), '');
|
||||
expect(PickupLocations.nameFor(''), '');
|
||||
expect(PickupLocations.nameFor(' '), '');
|
||||
expect(
|
||||
PickupLocations.nameFor(0),
|
||||
'',
|
||||
reason: '0 is how this backend spells "unset", not a location',
|
||||
);
|
||||
expect(PickupLocations.nameFor(7), 'Sri Balaji Stores');
|
||||
expect(PickupLocations.nameFor('7'), 'Sri Balaji Stores');
|
||||
});
|
||||
|
||||
test('reset empties it, so a sign-out cannot leak names across riders', () {
|
||||
PickupLocations.seed({'7': 'Sri Balaji Stores'});
|
||||
expect(PickupLocations.isLoaded, isTrue);
|
||||
PickupLocations.reset();
|
||||
expect(PickupLocations.isLoaded, isFalse);
|
||||
expect(PickupLocations.nameFor(7), '');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -66,19 +66,26 @@ void main() {
|
||||
// still holds is the one that matters: whatever the bar's surface is, the
|
||||
// type on it has to clear contrast against it, and every bar in the app has
|
||||
// to agree on the answer.
|
||||
testWidgets('is solid brand with a white title', (tester) async {
|
||||
testWidgets('is the brand ground with a white title', (tester) async {
|
||||
await pump(
|
||||
tester,
|
||||
Scaffold(appBar: milerPageBar('Saved Addresses', onBack: () {})),
|
||||
);
|
||||
|
||||
final bar = tester.widget<AppBar>(find.byType(AppBar));
|
||||
expect(bar.backgroundColor, ColorConstants.primary);
|
||||
expect(
|
||||
bar.flexibleSpace,
|
||||
isNull,
|
||||
reason: 'a solid ground needs no surface layered over it',
|
||||
);
|
||||
// ── The fill is transparent, and the surface paints ──
|
||||
//
|
||||
// It was the brand as a safety net behind [kBrandGradient]. It cannot be
|
||||
// one now: the bar's bottom corners are rounded so the page shows through
|
||||
// them (see [milerBrandSurface]), and an opaque rectangle behind a
|
||||
// rounded surface fills in exactly the two corners the curve exists to
|
||||
// open. What must hold is that the bar still *renders* the brand, which
|
||||
// is the gradient assertion below.
|
||||
expect(bar.backgroundColor, Colors.transparent);
|
||||
// [kBrandGradient], as a `flexibleSpace` — a colour cannot be a gradient.
|
||||
// The fill stays the brand red underneath, so a bar that fails to paint
|
||||
// its surface comes out the right colour rather than transparent.
|
||||
expect(bar.flexibleSpace, isNotNull);
|
||||
expect(
|
||||
bar.systemOverlayStyle?.statusBarIconBrightness,
|
||||
Brightness.light,
|
||||
|
||||
65
test/retired_platform_test.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE RETIRED PLATFORM IS NOT NAMED ANYWHERE
|
||||
///
|
||||
/// The delivery screens under `lib/xpress` are a verbatim port of a rider app
|
||||
/// that belonged to a platform which has since been shut down. The port
|
||||
/// brought its hostnames, its image host and its name in comments with it, and
|
||||
/// those references outlived the thing they referred to.
|
||||
///
|
||||
/// A dead product's name in a live repo is not a cosmetic problem. It sends the
|
||||
/// next reader looking for a backend that no longer answers, and it is exactly
|
||||
/// the sort of string that gets pasted into a config file by somebody trying to
|
||||
/// make a screen work.
|
||||
///
|
||||
/// ── Why the word is assembled rather than written ──
|
||||
///
|
||||
/// This file has to know the banned string in order to look for it, and a
|
||||
/// literal here would be the very thing it forbids — the ban would fail its own
|
||||
/// test. Joining two halves keeps the guard real and keeps the repo clean.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
// The retired platform's name, never spelled in one piece. See above.
|
||||
final banned = <String>[
|
||||
['nea', 'rle'].join(),
|
||||
];
|
||||
|
||||
test('no source, test or document names the retired platform', () {
|
||||
final offenders = <String>[];
|
||||
|
||||
for (final dir in const ['lib', 'test']) {
|
||||
for (final entity in Directory(dir).listSync(recursive: true)) {
|
||||
if (entity is! File) continue;
|
||||
if (!entity.path.endsWith('.dart')) continue;
|
||||
// This file is the one place the string legitimately exists, in two
|
||||
// halves — skip it rather than assemble a special case for it.
|
||||
if (entity.path.endsWith('retired_platform_test.dart')) continue;
|
||||
|
||||
final text = entity.readAsStringSync().toLowerCase();
|
||||
for (final word in banned) {
|
||||
if (text.contains(word)) offenders.add(entity.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final doc in Directory('.').listSync()) {
|
||||
if (doc is! File || !doc.path.endsWith('.md')) continue;
|
||||
final text = doc.readAsStringSync().toLowerCase();
|
||||
for (final word in banned) {
|
||||
if (text.contains(word)) offenders.add(doc.path);
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
isEmpty,
|
||||
reason:
|
||||
'the retired platform is named in: ${offenders.join(', ')} — it was '
|
||||
'shut down, so a reference to it points at nothing and reads as a '
|
||||
'live dependency to the next person through this file',
|
||||
);
|
||||
});
|
||||
}
|
||||
357
test/rider_lifecycle_test.dart
Normal file
@@ -0,0 +1,357 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/data/rider_stage.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE RIDER-FACING LIFECYCLE, END TO END
|
||||
///
|
||||
/// ```
|
||||
/// Pending → Accepted → Arrived → Picked → Active → Delivered
|
||||
/// ```
|
||||
///
|
||||
/// Six stages against a backend lifecycle that has five and no arrival in it.
|
||||
/// The gap is not an oversight on their side: arrival is an **event**, written
|
||||
/// by `/reached` as a timestamp beside a status that does not move, and asking
|
||||
/// them to add a rung would be asking them to model an event as a state.
|
||||
///
|
||||
/// So the app owes the richer workflow, and these hold it to it. Two resolvers
|
||||
/// are exercised, deliberately, because they answer different questions about
|
||||
/// the same order:
|
||||
///
|
||||
/// • [riderStageOf] — where is this order in the rider's day?
|
||||
/// • [MilkRun.stageOf] — how far did the *pickup* get?
|
||||
///
|
||||
/// A stop whose delivery is under way is `Active` on the first and `Picked` on
|
||||
/// the second, and both are correct. That separation is the point of the whole
|
||||
/// exercise, so it is asserted rather than assumed.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
const id = 'B1';
|
||||
Map<String, dynamic> row(String status) => {
|
||||
'orderid': id,
|
||||
'pickupid': id,
|
||||
'sourcename': 'Vidhya Kitchen',
|
||||
'orderstatus': status,
|
||||
};
|
||||
|
||||
RiderStage stage(
|
||||
String status, {
|
||||
Set<String> arrived = const {},
|
||||
Set<String> collected = const {},
|
||||
Set<String> released = const {},
|
||||
Set<String> delivered = const {},
|
||||
}) => riderStageOf(
|
||||
row(status),
|
||||
arrivedIds: arrived,
|
||||
collectedIds: collected,
|
||||
outForDeliveryIds: released,
|
||||
deliveredIds: delivered,
|
||||
);
|
||||
|
||||
group('1 · the six stages, walked once in order', () {
|
||||
test('Pending — the offer', () {
|
||||
expect(stage('miler_assigned'), RiderStage.pending);
|
||||
expect(stage('pending'), RiderStage.pending);
|
||||
});
|
||||
|
||||
test('Accepted — pickup_scheduled, and nothing else', () {
|
||||
// The backend's own word for "the rider took it".
|
||||
expect(stage('pickup_scheduled'), RiderStage.accepted);
|
||||
});
|
||||
|
||||
test(
|
||||
'Arrived — the arrival record, because no server field carries it',
|
||||
() {
|
||||
// `/reached` writes a timestamp; `GET /miler/bookings` does not return
|
||||
// it. Until it does, the rider's own record is the only evidence there
|
||||
// is — see `getArrivedOrderIds`.
|
||||
expect(stage('pickup_scheduled', arrived: {id}), RiderStage.arrived);
|
||||
},
|
||||
);
|
||||
|
||||
test('Picked — either backend word, or the collected record', () {
|
||||
expect(stage('converted_to_consignment'), RiderStage.picked);
|
||||
expect(stage('picked_up'), RiderStage.picked);
|
||||
expect(stage('pickup_scheduled', collected: {id}), RiderStage.picked);
|
||||
});
|
||||
|
||||
test('Active — the delivery is under way', () {
|
||||
expect(stage('out_for_delivery'), RiderStage.active);
|
||||
expect(stage('active'), RiderStage.active);
|
||||
expect(
|
||||
stage('converted_to_consignment', released: {id}),
|
||||
RiderStage.active,
|
||||
reason: 'the rider pressed Start delivery; the round has begun',
|
||||
);
|
||||
});
|
||||
|
||||
test('Delivered — terminal', () {
|
||||
expect(stage('delivered'), RiderStage.delivered);
|
||||
});
|
||||
|
||||
test('Cancelled sits outside the run', () {
|
||||
expect(stage('cancelled'), RiderStage.cancelled);
|
||||
expect(stage('rejected'), RiderStage.cancelled);
|
||||
});
|
||||
|
||||
test('the stages are ordered, and each is its own', () {
|
||||
final walked = [
|
||||
stage('miler_assigned'),
|
||||
stage('pickup_scheduled'),
|
||||
stage('pickup_scheduled', arrived: {id}),
|
||||
stage('converted_to_consignment'),
|
||||
stage('out_for_delivery'),
|
||||
stage('delivered'),
|
||||
];
|
||||
expect(walked.toSet(), hasLength(6), reason: 'six distinct stages');
|
||||
for (var i = 1; i < walked.length; i++) {
|
||||
expect(
|
||||
walked[i].rank,
|
||||
greaterThan(walked[i - 1].rank),
|
||||
reason: '${walked[i]} must rank above ${walked[i - 1]}',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('2 · progress never runs backwards', () {
|
||||
test('a stale arrival cannot un-pick a collected stop', () {
|
||||
expect(
|
||||
stage('converted_to_consignment', arrived: {id}),
|
||||
RiderStage.picked,
|
||||
);
|
||||
});
|
||||
|
||||
test('a stale arrival cannot un-start a round', () {
|
||||
expect(stage('out_for_delivery', arrived: {id}), RiderStage.active);
|
||||
});
|
||||
|
||||
test('a stale collection cannot un-deliver an order', () {
|
||||
expect(
|
||||
stage('delivered', arrived: {id}, collected: {id}),
|
||||
RiderStage.delivered,
|
||||
);
|
||||
});
|
||||
|
||||
test('no local record can revive a cancelled stop', () {
|
||||
expect(
|
||||
stage('cancelled', arrived: {id}, collected: {id}, released: {id}),
|
||||
RiderStage.cancelled,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('3 · the two resolvers answer different questions', () {
|
||||
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
StopStatus pickupRung(String status, {Set<String> collected = const {}}) =>
|
||||
MilkRun.stageOf(
|
||||
row(status),
|
||||
acceptedIds: const {},
|
||||
collectedIds: collected,
|
||||
);
|
||||
|
||||
test('delivery under way: Active overall, Picked on the pickup card', () {
|
||||
// The case the separation exists for, stated in one test.
|
||||
expect(stage('active', collected: {id}), RiderStage.active);
|
||||
expect(pickupRung('active', collected: {id}), StopStatus.picked);
|
||||
});
|
||||
|
||||
test('and the pickup milestone is readable from the stage too', () {
|
||||
expect(stage('out_for_delivery', collected: {id}).pickupComplete, isTrue);
|
||||
expect(stage('delivered').pickupComplete, isTrue);
|
||||
expect(stage('pickup_scheduled', arrived: {id}).pickupComplete, isFalse);
|
||||
expect(
|
||||
stage('cancelled').pickupComplete,
|
||||
isFalse,
|
||||
reason: 'a cancelled stop was never picked',
|
||||
);
|
||||
});
|
||||
|
||||
test('availability is not a booking status and cannot reach either', () {
|
||||
// `updateActiveStatus` posts `setAvailability('On_Pickup')` — a fact about
|
||||
// the **rider**. It has no booking status to write, and the regression it
|
||||
// caused was the app inventing one. Neither resolver takes availability,
|
||||
// and there is no spelling of it that parses as a rung.
|
||||
expect(stopStatusFromRaw('on_pickup'), StopStatus.unknown);
|
||||
expect(stopStatusFromRaw('On_Pickup'), StopStatus.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
group('3b · the authoritative contract, field by field', () {
|
||||
Map<String, dynamic> booking(
|
||||
String status, {
|
||||
Object? reachedAt,
|
||||
String? consignment,
|
||||
}) => {
|
||||
'orderid': id,
|
||||
'pickupid': id,
|
||||
'orderstatus': status,
|
||||
if (reachedAt != null) 'reachedat': reachedAt,
|
||||
if (consignment != null) 'consignmentstatus': consignment,
|
||||
};
|
||||
|
||||
test('Pickup_Scheduled + no reachedat → Accepted', () {
|
||||
expect(riderStageOf(booking('Pickup_Scheduled')), RiderStage.accepted);
|
||||
expect(
|
||||
riderStageOf(booking('Pickup_Scheduled', reachedAt: '')),
|
||||
RiderStage.accepted,
|
||||
reason: 'an empty stamp is not an arrival',
|
||||
);
|
||||
expect(
|
||||
riderStageOf(booking('Pickup_Scheduled', reachedAt: 'null')),
|
||||
RiderStage.accepted,
|
||||
reason: 'a stringified JSON null is not an arrival either',
|
||||
);
|
||||
});
|
||||
|
||||
test('Pickup_Scheduled + reachedat → Arrived, from the server alone', () {
|
||||
// No local record passed in: this is the rung rebuilt from server data,
|
||||
// which is what retires the compatibility store.
|
||||
expect(
|
||||
riderStageOf(
|
||||
booking('Pickup_Scheduled', reachedAt: '2026-08-25T12:33:02+05:30'),
|
||||
),
|
||||
RiderStage.arrived,
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed stamp is still an arrival, and does not throw', () {
|
||||
// Read for presence, not for value: the backend does not write the field
|
||||
// for a booking nobody arrived at, so an unparseable one is a bad clock
|
||||
// rather than a bad claim. Parsing it and discarding it would lose the
|
||||
// rung over a formatting detail.
|
||||
for (final junk in const ['not-a-date', 0, '2026-13-45', true]) {
|
||||
expect(
|
||||
riderStageOf(booking('Pickup_Scheduled', reachedAt: junk)),
|
||||
RiderStage.arrived,
|
||||
reason: 'reachedat="$junk" threw or was dropped',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Collected_By_Miler → Picked', () {
|
||||
expect(
|
||||
riderStageOf(
|
||||
booking(
|
||||
'Converted_To_Consignment',
|
||||
consignment: 'Collected_By_Miler',
|
||||
),
|
||||
),
|
||||
RiderStage.picked,
|
||||
);
|
||||
});
|
||||
|
||||
test('Out_for_Delivery → Active', () {
|
||||
expect(
|
||||
riderStageOf(
|
||||
booking('Converted_To_Consignment', consignment: 'Out_for_Delivery'),
|
||||
),
|
||||
RiderStage.active,
|
||||
);
|
||||
});
|
||||
|
||||
test('Delivered → Delivered', () {
|
||||
expect(
|
||||
riderStageOf(
|
||||
booking('Converted_To_Consignment', consignment: 'Delivered'),
|
||||
),
|
||||
RiderStage.delivered,
|
||||
);
|
||||
});
|
||||
|
||||
test('the consignment outranks a stale stamp at every rung', () {
|
||||
const stamp = '2026-08-25T12:33:02+05:30';
|
||||
for (final (state, expected) in const [
|
||||
('Collected_By_Miler', RiderStage.picked),
|
||||
('Out_for_Delivery', RiderStage.active),
|
||||
('Delivered', RiderStage.delivered),
|
||||
]) {
|
||||
expect(
|
||||
riderStageOf(
|
||||
booking('Pickup_Scheduled', reachedAt: stamp, consignment: state),
|
||||
arrivedIds: {id},
|
||||
collectedIds: {id},
|
||||
),
|
||||
expected,
|
||||
reason: '$state was dragged back by older evidence',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('CROSS-CITY · the hub\'s rungs are not this rider being Active', () {
|
||||
// Confirmed by the backend: a cross-city parcel goes
|
||||
// Collected_By_Miler → Inwarded_at_Hub → Tripsheet_Loaded → In_Transit
|
||||
// and is delivered by somebody else. The rider's involvement ended at
|
||||
// the collection, so Picked is where he stays — and no delivery control
|
||||
// may be offered for a stop he cannot act on.
|
||||
for (final hub in const [
|
||||
'Inwarded_at_Hub',
|
||||
'Tripsheet_Loaded',
|
||||
'In_Transit',
|
||||
'Created',
|
||||
]) {
|
||||
final at = riderStageOf(
|
||||
booking('Converted_To_Consignment', consignment: hub),
|
||||
);
|
||||
expect(
|
||||
at,
|
||||
RiderStage.picked,
|
||||
reason: '"$hub" resolved to $at — the hub half is not his round',
|
||||
);
|
||||
expect(at, isNot(RiderStage.active));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('4 · restored from persisted state after a restart', () {
|
||||
// Reconstruction reads the same evidence the live flow writes, so a
|
||||
// relaunch lands a stop exactly where the rider left it — the one thing a
|
||||
// derived lifecycle has to get right, because nothing is stored.
|
||||
test('pickup_scheduled with no local record → Accepted', () {
|
||||
expect(stage('pickup_scheduled'), RiderStage.accepted);
|
||||
});
|
||||
|
||||
test('pickup_scheduled with a surviving arrival record → Arrived', () {
|
||||
expect(stage('pickup_scheduled', arrived: {id}), RiderStage.arrived);
|
||||
});
|
||||
|
||||
test('converted_to_consignment → Picked, with no local record at all', () {
|
||||
expect(stage('converted_to_consignment'), RiderStage.picked);
|
||||
});
|
||||
|
||||
test('a released round comes back Active', () {
|
||||
expect(
|
||||
stage('converted_to_consignment', released: {id}),
|
||||
RiderStage.active,
|
||||
);
|
||||
});
|
||||
|
||||
test('a finished stop comes back Delivered', () {
|
||||
expect(stage('delivered'), RiderStage.delivered);
|
||||
});
|
||||
|
||||
test('nothing restores to an earlier rung than it reached', () {
|
||||
// Every server word, with a full set of stale local records attached.
|
||||
// None may pull the stop back.
|
||||
final stale = {id};
|
||||
for (final (said, expected) in const [
|
||||
('converted_to_consignment', RiderStage.picked),
|
||||
('picked_up', RiderStage.picked),
|
||||
('out_for_delivery', RiderStage.active),
|
||||
('active', RiderStage.active),
|
||||
('delivered', RiderStage.delivered),
|
||||
]) {
|
||||
expect(
|
||||
riderStageOf(row(said), arrivedIds: stale),
|
||||
expected,
|
||||
reason: '"$said" was dragged back by a stale arrival record',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
/// says so.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
_adapterCarriesTheStamp();
|
||||
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
@@ -198,7 +200,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('each source says out loud what it is', () {
|
||||
expect(RouteOrderSource.adminSequence.label, 'Hub route');
|
||||
expect(RouteOrderSource.adminSequence.label, 'Assigned route');
|
||||
expect(RouteOrderSource.proximity.label, 'Nearest first');
|
||||
expect(
|
||||
RouteOrderSource.proximity.explanation,
|
||||
@@ -351,3 +353,51 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// ── The adapter has to carry the stamp, or the contract is unreadable ──
|
||||
///
|
||||
/// `RouteOrder` gates on `sequencedat` exactly as the backend specified. That
|
||||
/// is worth nothing if the field never reaches it: `pickupFromBooking` mapped
|
||||
/// `step`, `stoptype`, `etaminutes`, `cumulativekms` and `cumulativeeta` and
|
||||
/// dropped the one that says whether any of them mean anything — so every
|
||||
/// adapted row looked unsequenced and the app fell back to nearest-first on
|
||||
/// routes the hub had solved.
|
||||
void _adapterCarriesTheStamp() {
|
||||
group('the adapter carries sequencedat', () {
|
||||
Map<String, dynamic> booking(Map<String, dynamic> extra) => {
|
||||
'bookingid': 1,
|
||||
'bookingno': 'B1',
|
||||
'pickupaddress': '19 Thadagam Road',
|
||||
...extra,
|
||||
};
|
||||
|
||||
test('a sequenced row arrives sequenced', () {
|
||||
final row = ApiConfig.pickupFromBooking(
|
||||
booking({'step': 2, 'sequencedat': '2026-08-25T09:00:00+05:30'}),
|
||||
);
|
||||
expect(RouteOrder.isSequenced(row), isTrue);
|
||||
expect(RouteOrder.sequenceOf(row), 2);
|
||||
});
|
||||
|
||||
test('step 0 with no stamp is not a route, and that is not a bug', () {
|
||||
// The backend's own words: `step: 0` + `sequencedat: null` means the
|
||||
// rider holds fewer than two active stops, or the stop has no coords.
|
||||
final row = ApiConfig.pickupFromBooking(
|
||||
booking({'step': 0, 'sequencedat': null}),
|
||||
);
|
||||
expect(RouteOrder.isSequenced(row), isFalse);
|
||||
expect(
|
||||
RouteOrder.hasAdminSequence([row]),
|
||||
isFalse,
|
||||
reason: 'nearest-first is the correct answer here, not a fallback bug',
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty stamp is no stamp', () {
|
||||
final row = ApiConfig.pickupFromBooking(
|
||||
booking({'step': 0, 'sequencedat': ' '}),
|
||||
);
|
||||
expect(RouteOrder.isSequenced(row), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/views/helpers/widgets/slide_action.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/data/accepted_store.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
|
||||
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/data/bag_manifest.dart';
|
||||
@@ -355,18 +356,21 @@ void main() {
|
||||
// file silently. The gesture goes dead and the sheet says why.
|
||||
expect(find.text('Nothing to collect'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Nothing was handed over — call the hub before you leave.'),
|
||||
find.text('Nothing was handed over — call your office before you leave.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
final gate = tester.widget<IgnorePointer>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byType(SlideToSubmit),
|
||||
matching: find.byType(IgnorePointer),
|
||||
)
|
||||
.first,
|
||||
// ── Disabled is a different shape, not a dimmed one ──
|
||||
//
|
||||
// It used to be the slide control wrapped in an `IgnorePointer`, which
|
||||
// is a control that still looks draggable and silently is not: the rider
|
||||
// drags, nothing happens, and he learns the sheet is broken. With
|
||||
// nothing to collect there is no slide control at all — just an inert
|
||||
// bar carrying the reason.
|
||||
expect(
|
||||
find.byType(MilerSlideAction),
|
||||
findsNothing,
|
||||
reason: 'a gesture that cannot fire must not be offered',
|
||||
);
|
||||
expect(gate.ignoring, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('the outcome splits what he took from what he did not', (
|
||||
|
||||
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 317 KiB |
@@ -98,27 +98,65 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('Arrived_At_Pickup is what confirms it', () async {
|
||||
// ── What confirms an arrival changed, on the backend's word ──
|
||||
//
|
||||
// This demanded `status: "Arrived_At_Pickup"`. The backend team has since
|
||||
// confirmed there is **no Arrived rung in the booking-status lifecycle**:
|
||||
// it runs `pickup_scheduled → converted_to_consignment → …`, and `/reached`
|
||||
// records the arrival beside it as a timestamp.
|
||||
//
|
||||
// So the old assertion demanded evidence of a transition the backend never
|
||||
// claimed to make, and `reached` answered *unconfirmed* forever — logging a
|
||||
// gap against a healthy endpoint on every arrival. The stamp is the proof;
|
||||
// the status echoing `pickup_scheduled` is correct and expected.
|
||||
test('the arrival stamp is what confirms it', () async {
|
||||
stub({
|
||||
'/reached': jsonRes({
|
||||
'success': true,
|
||||
'data': {'bookingid': 78, 'status': 'Arrived_At_Pickup'},
|
||||
'data': {
|
||||
'bookingid': 78,
|
||||
'status': 'pickup_scheduled',
|
||||
'reachedat': '2026-08-25T12:33:02+05:30',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
final t = MilerLifecycle.reached(await MilerApi.reached(78));
|
||||
expect(t.isConfirmed, isTrue);
|
||||
expect(t.bookingStatus, BookingStatus.arrivedAtPickup);
|
||||
expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived');
|
||||
expect(
|
||||
t.evidence,
|
||||
contains('2026-08-25T12:33:02+05:30'),
|
||||
reason: 'the log line has to say what the proof actually was',
|
||||
);
|
||||
// The status is untouched and unremarkable — that is the point.
|
||||
expect(t.bookingStatus, isNot(BookingStatus.unknown));
|
||||
});
|
||||
|
||||
test('a response naming no status at all is not confirmation', () async {
|
||||
test('a stamped arrival confirms whatever the status says', () async {
|
||||
// Including the old spelling, if a deployment still sends it: the stamp
|
||||
// is the evidence either way.
|
||||
stub({
|
||||
'/reached': jsonRes({
|
||||
'success': true,
|
||||
'data': {
|
||||
'status': 'Miler_Assigned',
|
||||
'reached_at': '2026-08-25T12:33:02Z',
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
MilerLifecycle.reached(await MilerApi.reached(78)).isConfirmed,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('a response with no arrival stamp is not confirmation', () async {
|
||||
stub({
|
||||
'/reached': jsonRes({'success': true, 'data': {}}),
|
||||
});
|
||||
final t = MilerLifecycle.reached(await MilerApi.reached(78));
|
||||
expect(t.isUnconfirmed, isTrue);
|
||||
expect(t.evidence, contains('no status'));
|
||||
expect(t.evidence, contains('no arrival stamp'));
|
||||
});
|
||||
|
||||
test('a refusal is a refusal, with its code', () async {
|
||||
|
||||
@@ -155,11 +155,31 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('and arriving is what puts him on the stop', () {
|
||||
test('and arriving is its own rung, not "active"', () {
|
||||
// ── The two used to be one, and it cost the rider his arrival ──
|
||||
//
|
||||
// `stopStateOf` folded `arrived` into [StopState.active], so the Home row
|
||||
// had no arrived to render and the live chip printed the literal word
|
||||
// **Active** the moment he slid *confirm arrival*. The rung had moved,
|
||||
// `/reached` had fired and the local record was written — and the one
|
||||
// word on screen said something else, which is indistinguishable from
|
||||
// the arrival not registering.
|
||||
//
|
||||
// They are different facts: arrived is a rung on the pickup ladder, and
|
||||
// active is the backend's word for a delivery under way.
|
||||
final stop = {'orderid': 'A1', 'orderstatus': 'arrived'};
|
||||
expect(
|
||||
stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}),
|
||||
StopState.arrived,
|
||||
);
|
||||
expect(
|
||||
stopStateOf(
|
||||
{'orderid': 'A1', 'orderstatus': 'active'},
|
||||
acceptedIds: const {},
|
||||
rejectedIds: const {},
|
||||
),
|
||||
StopState.active,
|
||||
reason: 'and active still means active',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE LAYERS HAVE TO BE VISIBLE TO BE LAYERS
|
||||
@@ -40,20 +41,35 @@ double contrast(Color a, Color b) {
|
||||
|
||||
void main() {
|
||||
group('the ladder has real rungs', () {
|
||||
test('a working surface separates from the canvas without a border', () {
|
||||
// ── This rung is now bought two ways, not one ──
|
||||
//
|
||||
// The old contract was: the tonal step alone has to carry the separation,
|
||||
// because a card had no border and no shadow — so anything under ~1.25:1
|
||||
// meant screens would start drawing their own outlines to compensate.
|
||||
//
|
||||
// Cards carry a hairline *and* a soft lift now (see [MilerPanel] and
|
||||
// [Narrative]), which is the brief's recipe rather than this one, and the
|
||||
// canvas went light to match. The step is deliberately smaller; what must
|
||||
// not happen is that it goes to nothing, because then the border is doing
|
||||
// the whole job and a card on a page reads as a box drawn on paper.
|
||||
test('a working surface still separates from the canvas', () {
|
||||
final step = contrast(MilerSurface.working, MilerSurface.canvas);
|
||||
expect(
|
||||
step,
|
||||
greaterThanOrEqualTo(1.25),
|
||||
reason:
|
||||
'white on the canvas is $step : 1 — below ~1.25 a surface does '
|
||||
'not read as a surface, and screens start drawing their own '
|
||||
'outlines to compensate',
|
||||
greaterThan(1.0),
|
||||
reason: 'white on the canvas is $step : 1 — the ground has vanished',
|
||||
);
|
||||
// And not so far that the page turns into a dark frame around content.
|
||||
expect(step, lessThan(1.6));
|
||||
});
|
||||
|
||||
test('a card carries the edge and the lift the ground no longer does', () {
|
||||
// The other half of the trade above. If either of these is dropped, the
|
||||
// canvas has to go back to carrying the step on its own.
|
||||
expect(Narrative.cardShadow, isNotEmpty);
|
||||
expect(Narrative.cardBorder, isNot(Narrative.card));
|
||||
});
|
||||
|
||||
test('the canvas is never white', () {
|
||||
expect(MilerSurface.canvas, isNot(MilerSurface.working));
|
||||
expect(MilerSurface.canvas, isNot(const Color(0xFFFFFFFF)));
|
||||
|
||||
@@ -225,6 +225,55 @@ void main() {
|
||||
final p = await resolveWith({'tenantname': 'nobody', 'tenantid': 4242});
|
||||
expect(p.line, ServiceLine.parcel);
|
||||
});
|
||||
|
||||
test('a known tenant id outranks a name that contradicts it', () async {
|
||||
// ── The loose end in a one-shot global flag ──
|
||||
//
|
||||
// Tenant 13 is the meal client, and its display name is whatever was
|
||||
// typed into the tenant record — plausibly the owning company's. A name
|
||||
// normalising to `doormile` matched the Logistics list, won on
|
||||
// name-first, and the id was never consulted.
|
||||
//
|
||||
// On Logistics `MyPickups._unreleased` is empty by design, so the
|
||||
// **Start round** bar does not render. With
|
||||
// `MILER_COLLECTED_STATE_ENABLED` on, that rider's collected parcels
|
||||
// strand at `Collected_By_Miler` with no control to release them.
|
||||
for (final name in const ['Doormile', 'doormile', 'DOOR-MILE']) {
|
||||
final p = await resolveWith({'tenantname': name, 'tenantid': 13});
|
||||
expect(
|
||||
p.line,
|
||||
ServiceLine.milkMan,
|
||||
reason: 'tenant 13 is the meal client whatever it is called ($name)',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a long legal name is harmless — it simply is not an answer', () async {
|
||||
// The case the backend team asked about. It matches neither list, so it
|
||||
// falls through to the id exactly as an empty name would.
|
||||
expect(
|
||||
TenantController.profileForName('Daily Grubs Foods Pvt Ltd'),
|
||||
isNull,
|
||||
);
|
||||
final p = await resolveWith({
|
||||
'tenantname': 'Daily Grubs Foods Pvt Ltd',
|
||||
'tenantid': 13,
|
||||
});
|
||||
expect(p.line, ServiceLine.milkMan);
|
||||
});
|
||||
|
||||
test('a tenant this build has never heard of is still decided by name',
|
||||
() async {
|
||||
// The property name-first existed for, and it must survive the change
|
||||
// above: a NEW meal client, absent from the id list, costs no release.
|
||||
final p = await resolveWith({'tenantname': 'milkrun', 'tenantid': 999});
|
||||
expect(p.line, ServiceLine.milkMan);
|
||||
|
||||
// And the mirror — a new logistics client is not dragged onto the meal
|
||||
// line by an id nobody has listed.
|
||||
final q = await resolveWith({'tenantname': 'logistics', 'tenantid': 998});
|
||||
expect(q.line, ServiceLine.parcel);
|
||||
});
|
||||
});
|
||||
|
||||
group('what each mode can and cannot do', () {
|
||||
|
||||
121
test/tenant_refresh_test.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE TENANT, REFRESHED WITHOUT A SIGN-OUT
|
||||
///
|
||||
/// `tenantname` was written at verify-pin and nowhere else, so it only reached
|
||||
/// a device by way of a **login**. A rider already signed in when the backend
|
||||
/// began returning the field would never receive it: prefs carry no name,
|
||||
/// resolution falls back to the tenant id, and an id this app has not been told
|
||||
/// about lands him on logistics — no **Start delivery** button, and a parcel
|
||||
/// stranded at `Collected_By_Miler` the moment collected-state is enabled.
|
||||
///
|
||||
/// `GET /miler/profile` returns the same pair, so the answer is refreshable in
|
||||
/// place. These pin that, and pin the three ways it must fail quietly: a launch
|
||||
/// with no signal, a signed-out device, and a handler that answers without the
|
||||
/// field.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
tearDown(() {
|
||||
MilerApi.client = http.Client();
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
});
|
||||
|
||||
void stubProfile(Map<String, dynamic> body, {int code = 200}) {
|
||||
MilerApi.client = MockClient(
|
||||
(req) async => http.Response(
|
||||
json.encode(body),
|
||||
code,
|
||||
headers: const {'content-type': 'application/json'},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test('a signed-in rider picks up his tenant name in place', () async {
|
||||
SharedPreferences.setMockInitialValues({'authtoken': 'tok'});
|
||||
stubProfile({
|
||||
'success': true,
|
||||
'data': {'tenantid': 41, 'tenantname': 'DailyGrubs'},
|
||||
});
|
||||
|
||||
await refreshTenantFromProfile();
|
||||
|
||||
expect(
|
||||
await resolveServiceProfile(),
|
||||
ServiceProfile.milkMan,
|
||||
reason: 'no sign-out should be required to receive the tenant',
|
||||
);
|
||||
});
|
||||
|
||||
test('the pair is read from inside `user` as well as top level', () async {
|
||||
// The backend returns it both ways; a handler that later moves it must not
|
||||
// silently stop resolving riders.
|
||||
SharedPreferences.setMockInitialValues({'authtoken': 'tok'});
|
||||
stubProfile({
|
||||
'success': true,
|
||||
'data': {
|
||||
'user': {'tenantid': 41, 'tenantname': 'Daily Grubs'},
|
||||
},
|
||||
});
|
||||
|
||||
await refreshTenantFromProfile();
|
||||
expect(await resolveServiceProfile(), ServiceProfile.milkMan);
|
||||
});
|
||||
|
||||
test('a signed-out device asks for nothing', () async {
|
||||
var called = false;
|
||||
MilerApi.client = MockClient((req) async {
|
||||
called = true;
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
SharedPreferences.setMockInitialValues(const {});
|
||||
await refreshTenantFromProfile();
|
||||
|
||||
expect(called, isFalse, reason: 'no token, no request');
|
||||
});
|
||||
|
||||
test('a launch with no signal keeps what it already had', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'authtoken': 'tok',
|
||||
TenantController.kTenantName: 'DailyGrubs',
|
||||
});
|
||||
MilerApi.client = MockClient((req) async => throw const SocketishFailure());
|
||||
|
||||
// Must not throw: a launch offline lands on the same screen it always did.
|
||||
await refreshTenantFromProfile();
|
||||
expect(await resolveServiceProfile(), ServiceProfile.milkMan);
|
||||
});
|
||||
|
||||
test('a response without the field does not erase the stored one', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'authtoken': 'tok',
|
||||
TenantController.kTenantName: 'DailyGrubs',
|
||||
});
|
||||
stubProfile({
|
||||
'success': true,
|
||||
'data': {'displayname': 'Rajan A'},
|
||||
});
|
||||
|
||||
await refreshTenantFromProfile();
|
||||
// Backgrounded for a known rider, so let it land before asserting.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(await resolveServiceProfile(), ServiceProfile.milkMan);
|
||||
});
|
||||
}
|
||||
|
||||
/// A stand-in for a dead network, so the test does not depend on `dart:io`
|
||||
/// error construction details.
|
||||
class SocketishFailure implements Exception {
|
||||
const SocketishFailure();
|
||||
}
|
||||
101
test/tenantname_unblocks_test.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHAT `tenantname` ON THE LOGIN IS WORTH
|
||||
///
|
||||
/// The backend gates `MILER_COLLECTED_STATE_ENABLED` on every hyperlocal rider
|
||||
/// having a **Start delivery** button, because with the flag on a parcel with
|
||||
/// nobody to release it sits at `Collected_By_Miler` forever.
|
||||
///
|
||||
/// That button is not a build flag. It is [ServiceProfile.deliversToCustomer],
|
||||
/// resolved per-login from the rider's tenant — so the question is not *which
|
||||
/// build is installed* but *does this rider resolve to the milk-run profile*.
|
||||
///
|
||||
/// ── Two ways to answer, and only one of them scales ──
|
||||
///
|
||||
/// ```
|
||||
/// by id a hardcoded set, {13} today. A client whose id we were never
|
||||
/// told falls through to logistics and gets no button.
|
||||
/// by name matched against `tenantname`, checked FIRST. `dailygrubs` is
|
||||
/// already on the list, so the client resolves with no release.
|
||||
/// ```
|
||||
///
|
||||
/// `tenantname` is not deployed yet, which is the whole of the risk: every
|
||||
/// hyperlocal tenant that is not id 13 is currently a strand waiting for the
|
||||
/// flag to be turned on. These pin both halves — that the name is sufficient
|
||||
/// on its own, and what happens without it.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
test(
|
||||
'the name alone puts a DailyGrubs rider on the milk-run profile',
|
||||
() async {
|
||||
// Deliberately no tenant id: this is a client whose id this app has never
|
||||
// been told, which is exactly the case the id set cannot cover.
|
||||
SharedPreferences.setMockInitialValues({
|
||||
TenantController.kTenantName: 'DailyGrubs',
|
||||
});
|
||||
|
||||
final profile = await resolveServiceProfile();
|
||||
expect(profile, ServiceProfile.milkMan);
|
||||
expect(
|
||||
profile.deliversToCustomer,
|
||||
isTrue,
|
||||
reason: 'this is the flag that renders Start delivery',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('case and spacing do not decide a rider\'s day', () async {
|
||||
for (final spelling in const [
|
||||
'dailygrubs',
|
||||
'DAILYGRUBS',
|
||||
' DailyGrubs ',
|
||||
// How a human actually types a client name into a tenant record. Each of
|
||||
// these used to miss, and a miss is a rider with no Start delivery button
|
||||
// and a parcel stranded at `Collected_By_Miler`.
|
||||
'Daily Grubs',
|
||||
'daily-grubs',
|
||||
'DAILY_GRUBS',
|
||||
]) {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
TenantController.kTenantName: spelling,
|
||||
});
|
||||
expect(
|
||||
await resolveServiceProfile(),
|
||||
ServiceProfile.milkMan,
|
||||
reason: '"$spelling" did not resolve',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('without it, an unrecognised tenant falls back to logistics', () async {
|
||||
// The strand: flag on, newest build installed, no button to press, and the
|
||||
// parcel stays at `Collected_By_Miler` because nothing can release it.
|
||||
SharedPreferences.setMockInitialValues({TenantController.kTenantId: 17});
|
||||
|
||||
final profile = await resolveServiceProfile();
|
||||
expect(profile, ServiceProfile.parcel);
|
||||
expect(profile.deliversToCustomer, isFalse);
|
||||
});
|
||||
|
||||
test('the name decides when the id says nothing', () async {
|
||||
// Belt and braces: a hyperlocal client sitting on an id this app has not
|
||||
// been told about must still resolve from its name.
|
||||
//
|
||||
// Note 17 is *unlisted*, not contradictory — an id this build knows wins,
|
||||
// see `tenant_mode_test.dart`. This is the fall-through, which is the whole
|
||||
// reason a new client needs no release.
|
||||
SharedPreferences.setMockInitialValues({
|
||||
TenantController.kTenantName: 'DailyGrubs',
|
||||
TenantController.kTenantId: 17,
|
||||
});
|
||||
|
||||
expect(await resolveServiceProfile(), ServiceProfile.milkMan);
|
||||
});
|
||||
}
|
||||
@@ -78,14 +78,14 @@ void main() {
|
||||
await pump(tester, route(4, 2, 11)); // 18 stops, current is #5
|
||||
expect(find.text('4 of 18 done'), findsOneWidget);
|
||||
expect(
|
||||
find.text('On stop 5 of 18 · 2 skipped · 2h 10m left, then hub'),
|
||||
find.text('On stop 5 of 18 · 2 skipped · 2h 10m left, then base'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('drops the skip clause when nothing was skipped', (tester) async {
|
||||
await pump(tester, route(3, 0, 5));
|
||||
expect(find.text('On stop 4 of 9 · 2h 10m left, then hub'), findsOneWidget);
|
||||
expect(find.text('On stop 4 of 9 · 2h 10m left, then base'), findsOneWidget);
|
||||
});
|
||||
|
||||
// The card, its border, its shadow and its padding are gone — the rail sits
|
||||
@@ -153,10 +153,10 @@ void main() {
|
||||
expect(find.byIcon(LucideIcons.warehouse), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a finished route says head back to the hub', (tester) async {
|
||||
testWidgets('a finished route says head back to base', (tester) async {
|
||||
await pump(tester, route(6, 0, 0, current: false));
|
||||
expect(find.text('All stops done'), findsOneWidget);
|
||||
expect(find.text('Head back to the hub'), findsOneWidget);
|
||||
expect(find.text('Head back to base'), findsOneWidget);
|
||||
});
|
||||
|
||||
// The rail numbers the stops the same way the list below it does, and the
|
||||
|
||||
@@ -5,60 +5,46 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip_tabs.dart';
|
||||
|
||||
/// The Trip 1 / 2 / 3 bar is a **pinned** sliver on Home: it scrolls up with the
|
||||
/// page, then holds at the top while the stops pass underneath, so switching
|
||||
/// trips never costs a scroll back up.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE SELECTOR STAYS PUT, AND IT STAYS ONE HEIGHT
|
||||
///
|
||||
/// Two things here are worth a test.
|
||||
/// This file used to guard a pinned `SliverPersistentHeader`: the delegate had
|
||||
/// to declare a fixed extent, [TripSelector]'s content was type that grows with
|
||||
/// the system font, and the two disagreed by a few pixels at large settings —
|
||||
/// so `extentFor` derived an over-estimate and these tests measured it from
|
||||
/// 1.0× to 2.0× to prove it was never short.
|
||||
///
|
||||
/// **The extent is computed, and computed height is the classic way to clip a
|
||||
/// control.** A pinned sliver must declare a fixed height, and [TripTabs]
|
||||
/// deliberately has none — its own comment records that a hard `54.h` overflowed
|
||||
/// by a few pixels at large font settings. `extentFor` therefore derives the
|
||||
/// height from the text scale and over-estimates on purpose. If that estimate is
|
||||
/// ever short, the tabs are clipped inside the header, silently. So it is
|
||||
/// measured here from 1.0× to 2.0×.
|
||||
/// **None of that exists any more.** The selector moved into the brand header,
|
||||
/// which is fixed by being the Column's first child rather than by being a
|
||||
/// sliver, so there is no extent to declare and nothing to under-estimate.
|
||||
///
|
||||
/// **Pinning itself.** That the bar holds position while content scrolls under
|
||||
/// it is the entire point of the change, and a one-word edit (`pinned: false`)
|
||||
/// undoes it invisibly.
|
||||
/// What survives is the property the arithmetic was protecting, and it is now
|
||||
/// the widget's own: the control is a **fixed height** that does not move with
|
||||
/// the text scale. A header that grows a few points at 1.5× pushes the whole
|
||||
/// page down and re-lays the route out under the rider's thumb. It cannot,
|
||||
/// because nothing in the control is free-flowing type — the numeral is inside
|
||||
/// a `FittedBox` that scales it down into the space it has.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
// The view is forced to 390x844 logical below, so gestures must aim inside
|
||||
// that box — x=400 would land outside the viewport and hit nothing.
|
||||
const inViewport = Offset(195, 500);
|
||||
Map<String, dynamic> stop(String id) => {
|
||||
'orderid': id,
|
||||
'pickupid': id,
|
||||
'pickupcustomer': 'Sri Balaji Stores',
|
||||
'pickupaddress': '19, Thadagam Road, Coimbatore',
|
||||
'orderstatus': 'pending',
|
||||
};
|
||||
|
||||
const aboveKey = ValueKey('above');
|
||||
const belowKey = ValueKey('below');
|
||||
|
||||
final trips = [
|
||||
Trip.fromStops(
|
||||
id: 't1',
|
||||
stops: [
|
||||
{'orderid': 'A', 'type': 'pickup', 'pickupcustomer': 'Sri Balaji'},
|
||||
],
|
||||
),
|
||||
Trip.fromStops(
|
||||
id: 't2',
|
||||
stops: [
|
||||
{'orderid': 'B', 'type': 'delivery', 'pickupcustomer': 'Anand'},
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// Pumps the real delegate inside a scroll view, at a given text scale, and
|
||||
/// hands back the extent it reserved.
|
||||
Future<double> pump(
|
||||
Future<void> pump(
|
||||
WidgetTester tester, {
|
||||
required int trips,
|
||||
int selected = 0,
|
||||
double textScale = 1.0,
|
||||
ValueChanged<int>? onSelect,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1170, 2532);
|
||||
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
double? captured;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
@@ -70,142 +56,123 @@ void main() {
|
||||
child: child!,
|
||||
),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
captured = TripTabsHeader.extentFor(context);
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
key: aboveKey,
|
||||
height: 300,
|
||||
color: Colors.amber,
|
||||
),
|
||||
),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: TripTabsHeader(
|
||||
extent: captured!,
|
||||
trips: trips,
|
||||
selectedIndex: 1,
|
||||
onSelect: onSelect ?? (_) {},
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
key: belowKey,
|
||||
height: 2000,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TripSelector(
|
||||
trips: [
|
||||
for (var i = 0; i < trips; i++)
|
||||
Trip.fromStops(id: 't$i', stops: [stop('$i')]),
|
||||
],
|
||||
selectedIndex: selected,
|
||||
onSelect: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return captured!;
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
group('reserved extent fits the tabs', () {
|
||||
// 2.0 is beyond Android's slider but within what a determined user can set,
|
||||
// and it is the case the original 54.h bug was reported against.
|
||||
for (final scale in [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) {
|
||||
testWidgets('at ${scale}x text scale', (tester) async {
|
||||
final extent = await pump(tester, textScale: scale);
|
||||
group('the control is one height at every text scale', () {
|
||||
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
|
||||
testWidgets('at ${scale}x', (tester) async {
|
||||
await pump(tester, trips: 2, textScale: scale);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(
|
||||
tester.takeException(),
|
||||
isNull,
|
||||
reason: 'the tabs must not overflow the reserved extent',
|
||||
);
|
||||
|
||||
final tabs = tester.getSize(find.byType(TripTabs));
|
||||
expect(
|
||||
tabs.height,
|
||||
lessThanOrEqualTo(extent + 0.01),
|
||||
tester.getSize(find.byType(TripSelector)).height,
|
||||
TripSelector.height,
|
||||
reason:
|
||||
'tabs are ${tabs.height} tall but only $extent was '
|
||||
'reserved — the bar would be clipped',
|
||||
'the header must not change height with the font, or the whole '
|
||||
'route below it moves under the rider\'s thumb',
|
||||
);
|
||||
|
||||
// Over-reserving is the safe direction, but not by a wasteful amount.
|
||||
expect(
|
||||
extent - tabs.height,
|
||||
lessThan(14),
|
||||
reason:
|
||||
'reserving $extent for ${tabs.height} of content is '
|
||||
'more dead space than the safety margin intends',
|
||||
);
|
||||
|
||||
// All three slots are still rendered and readable at this scale.
|
||||
for (final label in ['Trip 1', 'Trip 2', 'Trip 3']) {
|
||||
expect(find.text(label), findsOneWidget);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('pinning', () {
|
||||
testWidgets('the bar holds at the top while content scrolls under it', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester);
|
||||
final atRest = tester.getRect(find.byType(TripTabs));
|
||||
// It starts below the 300pt block above it, not stuck to the top.
|
||||
expect(atRest.top, greaterThan(200));
|
||||
testWidgets('three slots render before the hub has filled them', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester, trips: 1);
|
||||
for (final n in ['Trip 1', 'Trip 2', 'Trip 3']) {
|
||||
expect(find.text(n), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
await tester.dragFrom(inViewport, const Offset(0, -600));
|
||||
await tester.pumpAndSettle();
|
||||
testWidgets('exactly one segment is marked, and it is the selected one', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester, trips: 2, selected: 1);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
final pinned = tester.getRect(find.byType(TripTabs));
|
||||
expect(
|
||||
pinned.top,
|
||||
lessThan(atRest.top),
|
||||
reason: 'it should travel up with the page first',
|
||||
);
|
||||
expect(
|
||||
pinned.top,
|
||||
lessThan(40),
|
||||
reason: 'and then hold near the top of the viewport',
|
||||
);
|
||||
final marks = find.byKey(TripSelector.markerKey);
|
||||
expect(marks, findsOneWidget, reason: 'one trip is current, not two');
|
||||
|
||||
// Scrolling further does not move it again.
|
||||
await tester.dragFrom(inViewport, const Offset(0, -600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getRect(find.byType(TripTabs)), pinned);
|
||||
});
|
||||
// The rule is a short centred mark under its pill rather than a full-width
|
||||
// underline, so what has to hold is that it is centred on the *right*
|
||||
// pill — not that it spans it.
|
||||
final marker = tester.getRect(marks);
|
||||
final second = tester.getRect(find.text('Trip 2'));
|
||||
expect(
|
||||
marker.center.dx,
|
||||
closeTo(second.center.dx, 6),
|
||||
reason: 'the rule sits under the pill it belongs to',
|
||||
);
|
||||
expect(
|
||||
marker.top,
|
||||
greaterThan(second.bottom),
|
||||
reason: 'and under it, not through it',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('what sits above it does scroll away', (tester) async {
|
||||
await pump(tester);
|
||||
expect(find.byKey(aboveKey), findsOneWidget);
|
||||
testWidgets('the indicator is a rule, not a filled tab', (tester) async {
|
||||
// The tabs sit straight on the brand ground: no card, no wash, no outline.
|
||||
// Selection is the travelling rule and the ink weight, and nothing else.
|
||||
await pump(tester, trips: 2);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
await tester.dragFrom(inViewport, const Offset(0, -900));
|
||||
await tester.pumpAndSettle();
|
||||
final marker = tester.getSize(find.byKey(TripSelector.markerKey));
|
||||
expect(
|
||||
marker.height,
|
||||
lessThanOrEqualTo(4),
|
||||
reason: 'a 2-3dp rule, not a segment-sized fill',
|
||||
);
|
||||
});
|
||||
|
||||
// The summary / shift / metrics block is meant to leave; only the control
|
||||
// stays. A sliver that has left the viewport and its cache is unmounted.
|
||||
expect(find.byKey(aboveKey), findsNothing);
|
||||
expect(find.byType(TripTabs), findsOneWidget);
|
||||
});
|
||||
testWidgets('a segment is tappable and reports its own index', (
|
||||
tester,
|
||||
) async {
|
||||
int? chosen;
|
||||
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
testWidgets('a tab is still tappable once pinned', (tester) async {
|
||||
var picked = -1;
|
||||
await pump(tester, onSelect: (i) => picked = i);
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TripSelector(
|
||||
trips: [
|
||||
Trip.fromStops(id: 't0', stops: [stop('0')]),
|
||||
Trip.fromStops(id: 't1', stops: [stop('1')]),
|
||||
],
|
||||
selectedIndex: 0,
|
||||
onSelect: (i) => chosen = i,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.dragFrom(inViewport, const Offset(0, -900));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Trip 1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
picked,
|
||||
0,
|
||||
reason: 'switching trips from deep in the list is the whole point',
|
||||
);
|
||||
});
|
||||
await tester.tap(find.text('Trip 2'));
|
||||
expect(chosen, 1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -458,7 +458,9 @@ void main() {
|
||||
acceptedIds: const {},
|
||||
rejectedIds: const {},
|
||||
),
|
||||
StopState.active,
|
||||
// Its own rung since the live chip stopped printing `Active` over it —
|
||||
// see [StopState.arrived].
|
||||
StopState.arrived,
|
||||
);
|
||||
expect(
|
||||
stopStateOf(
|
||||
@@ -657,7 +659,9 @@ void main() {
|
||||
acceptedIds: const {},
|
||||
rejectedIds: {'a'},
|
||||
),
|
||||
StopState.active,
|
||||
// Arrived is its own rung now — see [StopState.arrived]. What the
|
||||
// test is about is unchanged: a server rung outranks a local decision.
|
||||
StopState.arrived,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ void main() {
|
||||
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 a hub', (
|
||||
testWidgets('starts at a kitchen, and never comes back to base', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpRoute(
|
||||
@@ -104,8 +104,8 @@ void main() {
|
||||
// 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 · HUB'), findsNothing);
|
||||
expect(find.text('START · HUB'), findsNothing);
|
||||
expect(find.text('RETURN · BASE'), findsNothing);
|
||||
expect(find.text('START · BASE'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('sees his route grouped under the kitchen it comes from', (
|
||||
@@ -172,7 +172,7 @@ void main() {
|
||||
group('a rider who logs in without a meal tenant', () {
|
||||
setUp(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
testWidgets('gets the hub-to-hub route he has always had', (tester) async {
|
||||
testWidgets('gets the base-to-base route he has always had', (tester) async {
|
||||
await pumpRoute(
|
||||
tester,
|
||||
stops: [
|
||||
@@ -185,7 +185,7 @@ void main() {
|
||||
// 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 · HUB'), findsOneWidget);
|
||||
expect(find.text('RETURN · BASE'), findsOneWidget);
|
||||
expect(find.text('START · KITCHEN'), findsNothing);
|
||||
});
|
||||
|
||||
@@ -324,7 +324,7 @@ void main() {
|
||||
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 hub'));
|
||||
expect(line, contains('then base'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||