Files
doormile_milderapp/test/trip_progress_test.dart
2026-08-28 11:13:15 +05:30

323 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_progress_rail.dart';
import 'package:miler/Models/stop_status.dart';
/// The whole point of replacing the node rail is that route length no longer
/// changes the widget's shape. A hub can assign 6 stops or 40; the summary at
/// the top of Bookings has to look the same either way.
void main() {
Widget app(List<StopProgress> progress) {
return ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripProgressRail(
progress: progress,
slotLabel: '10:00 AM – 1:00 PM',
remaining: const Duration(minutes: 130),
),
),
),
),
);
}
Future<void> pump(WidgetTester tester, List<StopProgress> progress) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(app(progress));
await tester.pumpAndSettle();
}
List<StopProgress> route(
int done,
int skipped,
int pending, {
bool current = true,
}) {
return [
for (var i = 0; i < done; i++) StopProgress.done,
if (current) StopProgress.current,
for (var i = 0; i < skipped; i++) StopProgress.skipped,
for (var i = 0; i < pending; i++) StopProgress.pending,
];
}
testWidgets('height does not grow with the number of stops', (tester) async {
await pump(tester, route(2, 1, 3));
final short = tester.getSize(find.byType(TripProgressRail)).height;
await pump(tester, route(8, 3, 28)); // 40 stops
final long = tester.getSize(find.byType(TripProgressRail)).height;
expect(
long,
short,
reason: 'the summary must not stretch or squeeze with route length',
);
});
testWidgets('never overflows, even on a long route', (tester) async {
await pump(tester, route(20, 5, 60)); // 86 stops
expect(tester.takeException(), isNull);
});
// Position, skips and time left are ONE line now, not two ends of a Row.
// They are one sentence about one trip, and splitting them left a gap in the
// middle wide enough to read as two unrelated facts.
testWidgets('reports position, skips and the count', (tester) async {
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 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 base'), findsOneWidget);
});
// The card, its border, its shadow and its padding are gone — the rail sits
// straight on the page above the stop list. This is the assertion that keeps
// it that way: a future "let's give it an edge" change has to come here first.
testWidgets('draws no box of its own', (tester) async {
await pump(tester, route(4, 2, 11));
final boxes = tester
.widgetList<Container>(
find.descendant(
of: find.byType(TripProgressRail),
matching: find.byType(Container),
),
)
.map((c) => c.decoration)
.whereType<BoxDecoration>();
for (final d in boxes) {
// ── A ring on a node is a mark; a border on a rectangle is a box ──
//
// The rule this protects is that the rail sits bare on the page — no
// card, no panel, no outline around the run. Circular nodes are exempt
// and always were in spirit: the route timeline draws hollow rings for
// exactly the same reason, and the rail's two framing nodes (where the
// rider is, where his day ends) are hollow so a place he does not work
// never competes with one he does.
if (d.shape == BoxShape.circle) continue;
expect(d.border, isNull, reason: 'no bordered container inside the rail');
expect(
d.boxShadow,
anyOf(isNull, isEmpty),
reason: 'no elevation inside the rail',
);
}
});
// ── A run of one is a journey, not a badge ──
//
// A rider with a single accepted order met one red circled **1** at the top
// of Deliveries: a counter, or a notification badge, but not a route. The rail
// is framed by the two places the run actually goes between — where he is
// standing, and where the day ends — so even one stop reads as
// "here → this pickup → done".
testWidgets('one stop still reads as a journey', (tester) async {
await pump(tester, route(1, 0, 0));
expect(
find.byIcon(LucideIcons.locateFixed),
findsOneWidget,
reason: 'the run starts where the rider is',
);
// Parcel is the default profile in these tests: its day ends at the depot.
expect(find.byIcon(LucideIcons.warehouse), findsOneWidget);
expect(find.text('1'), findsOneWidget);
});
testWidgets('a milk run ends at home, not at a warehouse', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
await pump(tester, route(1, 0, 0));
expect(find.byIcon(LucideIcons.house), findsOneWidget);
expect(find.byIcon(LucideIcons.warehouse), findsNothing);
});
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 base'), findsOneWidget);
});
// The rail numbers the stops the same way the list below it does, and the
// finished ones are the coloured ones. This is the whole point of the nodes.
testWidgets('draws a numbered node per stop, and one scooter', (
tester,
) async {
await pump(tester, route(2, 0, 2)); // 5 stops, current is #3
// Every stop keeps its number, including the one he is on — the scooter
// rides above the node rather than replacing its digit.
for (final n in ['1', '2', '3', '4', '5']) {
expect(find.text(n), findsOneWidget, reason: 'stop $n is on the rail');
}
expect(find.byIcon(LucideIcons.bike), findsOneWidget);
});
// The point of the overlay: finishing a stop *moves* the marker, visibly,
// instead of repainting two circles.
testWidgets('the scooter rides forward when a stop is completed', (
tester,
) async {
Finder scooter() => find.byIcon(LucideIcons.bike);
await pump(tester, route(2, 0, 2)); // on stop 3 of 5
final from = tester.getCenter(scooter()).dx;
// Not `pump()` — that settles, and the whole assertion is about the frames
// in between.
await tester.pumpWidget(app(route(3, 0, 1))); // stop 3 done, now on 4
await tester.pump(const Duration(milliseconds: 120));
final moving = tester.getCenter(scooter()).dx;
await tester.pumpAndSettle();
final to = tester.getCenter(scooter()).dx;
expect(to, greaterThan(from), reason: 'it advanced one stop');
expect(moving, greaterThan(from));
expect(moving, lessThan(to), reason: 'it travelled rather than jumped');
});
testWidgets('a finished route parks the scooter', (tester) async {
await pump(tester, route(2, 0, 3, current: false)); // 5 stops, none active
// Nothing is `current`, so the first pending stop holds the scooter.
expect(find.byIcon(LucideIcons.bike), findsOneWidget);
await pump(tester, route(5, 0, 0, current: false)); // all done
expect(
find.byIcon(LucideIcons.bike),
findsNothing,
reason: 'nothing left to ride to',
);
});
// Long routes scroll rather than shrink — the failure that killed the first
// version of this rail was nodes being squeezed to fit a fixed width.
testWidgets('a long route scrolls instead of crushing its nodes', (
tester,
) async {
await pump(tester, route(4, 0, 35)); // 40 stops
expect(find.byType(SingleChildScrollView), findsWidgets);
expect(tester.takeException(), isNull);
});
testWidgets('the old per-stop hub labels are gone', (tester) async {
await pump(tester, route(2, 0, 4));
expect(find.text('START'), findsNothing);
expect(find.text('RETURN'), findsNothing);
});
// ── The scrolling edge has to read as "more route", not as a clip ──
//
// A 21-stop round draws about seven nodes on a 390pt phone and sliced the
// eighth down its middle at the viewport edge. A half circle stopping in mid
// air reads as a rendering fault, and it is the one edge that has to invite
// the push that reveals the rest of the day.
testWidgets('a scrolling rail fades at its edges; a short one does not', (
tester,
) async {
await pump(tester, route(4, 0, 35)); // 40 stops — scrolls
expect(
find.descendant(
of: find.byType(TripProgressRail),
matching: find.byType(ShaderMask),
),
findsOneWidget,
reason: 'the rail continues past the edge and must say so',
);
// Four. The rail reserves a framing node at each end and holds a 22pt
// floor under the gaps, so against a 390pt phone's 358pt of content width
// only six nodes — four stops — lay out without scrolling. Which is worth
// knowing on its own: the fade is not a long-route embellishment, it is
// what almost every real round gets.
await pump(tester, route(1, 0, 2)); // 4 stops — fits
expect(
find.descendant(
of: find.byType(TripProgressRail),
matching: find.byType(ShaderMask),
),
findsNothing,
reason:
'a route that fits ends where it ends — fading it would hint at '
'work that does not exist',
);
});
// ── The rung → rail mapping, in both legs' vocabulary ──
//
// `stopProgressFor` is read on the Deliveries tab, where every stop is past
// `pickup-complete` by definition. It used to ask `isActive || arrived` —
// both pickup-leg rungs — so the customer's door the rider was standing at
// matched nothing and drew grey.
group('stopProgressFor', () {
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
test('the door the rider is at is where the scooter goes', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(
stopProgressFor(StopStatus.deliveryArrived),
StopProgress.current,
reason:
'he is standing at it — this is the whole question the rail '
'answers',
);
});
test('the pickup leg still resolves', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(stopProgressFor(StopStatus.arrived), StopProgress.current);
expect(stopProgressFor(StopStatus.active), StopProgress.current);
});
test('a carried bag is not a delivered one', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
// `picked` and `outForDelivery` are both "aboard, not handed over". They
// are ahead of the rider, not behind him — see `picked_not_active_test`.
expect(stopProgressFor(StopStatus.picked), StopProgress.pending);
expect(stopProgressFor(StopStatus.outForDelivery), StopProgress.pending);
expect(stopProgressFor(StopStatus.delivered), StopProgress.done);
});
test('done is line-aware', () {
ServiceProfile.setActive(ServiceProfile.parcel);
expect(
stopProgressFor(StopStatus.picked),
StopProgress.done,
reason: 'collection IS the job on logistics',
);
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(
stopProgressFor(StopStatus.picked),
StopProgress.pending,
reason: 'on a round the same rung is the middle of the morning',
);
});
test('a skipped stop keeps its own state', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(stopProgressFor(StopStatus.skipped), StopProgress.skipped);
});
});
}