323 lines
12 KiB
Dart
323 lines
12 KiB
Dart
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);
|
||
});
|
||
});
|
||
}
|