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

570 lines
22 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 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
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';
/// ─────────────────────────────────────────────────────────────────────────
/// ACTIVITY, AS A DELIVERY HISTORY
///
/// The page used to be a day's worth of rows headed by the customer's name —
/// the one fact on a finished stop a rider cannot act on, and the one he is not
/// scanning for. History is opened to answer "did that go through?", and the
/// answer to that is a word.
///
/// So the row leads with the outcome, identifies itself with the route
/// underneath, and carries its measurements as one line of running text. Above
/// the list: what this screen is, and which slice of it. These hold that shape.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
String day(DateTime t) =>
'${t.year}-${t.month.toString().padLeft(2, '0')}-'
'${t.day.toString().padLeft(2, '0')}';
final now = DateTime.now();
Map<String, dynamic> done(
String id,
String who, {
int minsAgo = 20,
String status = 'delivered',
double cash = 0,
}) {
final at = now.subtract(Duration(minutes: minsAgo));
return {
'orderid': id,
'type': 'delivery',
'pickupcustomer': who,
'sourcename': 'Vidhya Kitchen',
'pickupaddress': '78, Cross Cut Road, Coimbatore',
'orderstatus': status,
'completedday': day(now),
'completedat': at.toIso8601String(),
'startedat': at.subtract(const Duration(minutes: 28)).toIso8601String(),
'compliance': const {'ontime': true, 'actualkm': 3.2, 'plannedkm': 3.0},
if (cash > 0) 'collectionamt': '$cash',
'proof': {'collected': 2, if (cash > 0) 'amountcollected': cash},
};
}
Map<String, dynamic> parked(String id, String who, String reason) => {
'orderid': id,
'type': 'delivery',
'pickupcustomer': who,
'sourcename': 'Vidhya Kitchen',
'pickupaddress': '19, Thadagam Road, Coimbatore',
'orderstatus': 'skipped',
'skipreason': reason,
'skippedday': day(now),
'skippedat': now.subtract(const Duration(minutes: 90)).toIso8601String(),
};
Future<void> pump(
WidgetTester tester, {
List<Map<String, dynamic>> completed = const [],
List<Map<String, dynamic>> skipped = const [],
bool settle = true,
}) async {
SharedPreferences.setMockInitialValues({'userId': 1});
await debugSeedStore('completed_bookings', jsonEncode(completed));
await debugSeedStore('skipped_bookings', jsonEncode(skipped));
// A phone's shape, and taller than one. The rows are cards now, so a
// default 800×600 harness cuts the list off part-way and a lazy `ListView`
// never builds what is past the fold — every assertion about a third row
// would then pass or fail on viewport height rather than on the widget.
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: (_, _) => const MaterialApp(home: ActivityPage()),
),
);
if (settle) {
await tester.pumpAndSettle(const Duration(seconds: 2));
} else {
await tester.pump();
}
}
/// Taps a filter chip, scrolling the bar to it first.
///
/// The bar scrolls on purpose — four chips do not fit a narrow phone at large
/// text scales, and a bar that reflowed to two lines would move the list under
/// the rider's thumb. A chip past the fold is built but outside the viewport,
/// and a tap at its centre lands on whatever is painted there instead.
Future<void> selectFilter(WidgetTester tester, String label) async {
await tester.scrollUntilVisible(
find.text(label),
80,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
await tester.tap(find.text(label));
await tester.pumpAndSettle();
}
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
group('the chrome', () {
testWidgets('the header answers how the shift is going', (tester) async {
await pump(
tester,
completed: [done('A1', 'Joe Mathew'), done('A2', 'Sarah')],
);
// The strapline said "Your recent delivery activity" under an app bar
// reading **Activity** — one line repeating the other. What a rider
// 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 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);
// 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 one travelling pill', (tester) async {
await pump(tester, completed: [done('A1', 'Joe Mathew')]);
// ── This assertion has moved three times, and the principle held ──
//
// 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.
//
// 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, Colors.white);
expect(selected.style!.fontWeight, FontWeight.w700);
final other = tester.widget<Text>(find.text('Completed'));
expect(other.style!.color, Narrative.inkMuted);
expect(other.style!.fontWeight, FontWeight.w600);
// 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 == Narrative.burgundy)
.toList();
expect(filled, hasLength(1), reason: 'four selected filters is none');
});
testWidgets('there is no chrome to operate on an empty day', (
tester,
) async {
await pump(tester);
// Nothing to filter, so the empty state owns the screen rather than
// sitting under a bar the rider cannot usefully press.
expect(find.text('Your recent delivery activity'), findsNothing);
expect(find.text('Completed'), findsNothing);
expect(find.textContaining('Go to'), findsOneWidget);
});
});
group('the row', () {
testWidgets('leads with the outcome, in the outcome\'s colour', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, completed: [done('A1', 'Joe Mathew')]);
// ── 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, 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(
name.style!.fontSize,
greaterThan(word.style!.fontSize!),
reason: 'the customer outranks the status word',
);
// Where it came from is the supporting line under it — one line now,
// both ends together, rather than the source as a separate fragment.
expect(find.textContaining('Vidhya Kitchen'), findsOneWidget);
// ── The booking reference is gone from the row ──
//
// It was a footer here, which is already quiet, but quiet is not the
// question: it is a lookup key an office reads down a phone, and a
// rider scanning his own shift should not be passing over database
// identifiers to reach a name. It is on the record page, one tap away.
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.
expect(find.text('78, Cross Cut Road, Coimbatore'), findsNothing);
});
testWidgets('a collection with no source names one end only', (
tester,
) async {
// A parcel booking is a first-mile collection *from* the customer, and
// where it goes next is the hub's problem — there is no second name to
// print, and inventing an arrow to nowhere would read as a route.
await pump(
tester,
completed: [done('A1', 'Sundar Textiles', status: 'picked')],
);
// The customer IS the collection point on this line, so the name leads
// and there is no second end to name under it.
expect(find.text('Sundar Textiles'), findsOneWidget);
expect(find.byIcon(LucideIcons.store), findsNothing);
});
});
group('the slices', () {
testWidgets('Completed drops the parked work', (tester) async {
await pump(
tester,
completed: [done('A1', 'Joe Mathew', status: 'picked')],
skipped: [parked('A9', 'Sri Balaji Stores', 'Shutter closed')],
);
expect(find.textContaining('Skipped'), findsOneWidget);
await selectFilter(tester, 'Completed');
expect(find.textContaining('Skipped'), findsNothing);
expect(find.textContaining('Picked up'), findsOneWidget);
// The lifted band is All's alone: a list of nothing but parked work does
// not need a heading saying so.
expect(find.text('NEEDS A RETURN VISIT'), findsNothing);
});
testWidgets('an empty slice says so about itself, with a way back', (
tester,
) async {
await pump(
tester,
completed: [done('A1', 'Joe Mathew', status: 'picked')],
);
await selectFilter(tester, 'Cancelled');
// A fact about the slice, never an apology — and never the day's own
// empty state, which would tell the rider he had done nothing.
//
// `Show all` is the slice's own way out; the day's empty state offers
// `Go to Bookings` instead. Asserting on the slice's copy is the
// unambiguous half — both states carry *a* button, and which one is
// showing is what this test is about.
expect(find.text('Nothing was cancelled.'), findsOneWidget);
expect(find.text('Show all'), findsOneWidget);
await tester.tap(find.widgetWithText(MilerButton, 'Show all'));
await tester.pumpAndSettle();
expect(find.textContaining('Picked up'), findsOneWidget);
});
});
group('grouping — the rider’s trips, not the clock’s bands', () {
/// A stop finished inside a given hour today, so the day-part rule places
/// it in a known trip. See [DayPart].
Map<String, dynamic> atHour(String id, String who, int hour) {
final at = DateTime(now.year, now.month, now.day, hour, 15);
return {
...done(id, who),
'completedat': at.toIso8601String(),
'expected_pickup_time': at.toIso8601String(),
};
}
testWidgets('one run needs no accordion — it opens as itself', (
tester,
) async {
await pump(
tester,
completed: [
atHour('A1', 'Joe Mathew', 9),
atHour('A2', 'Green Leaf', 10),
],
);
// A single header the rider must tap to see the only thing on the page
// is a lock on an empty door.
expect(find.text('Trip 1'), findsOneWidget);
expect(find.text('Joe Mathew'), findsOneWidget);
expect(find.text('Green Leaf'), findsOneWidget);
expect(find.byIcon(LucideIcons.chevronDown), findsNothing);
});
testWidgets('three runs become three headers, all closed', (tester) async {
await pump(
tester,
completed: [
atHour('A1', 'Joe Mathew', 9),
atHour('A2', 'Green Leaf', 14),
atHour('A3', 'Sri Balaji', 19),
],
);
expect(find.text('Trip 1'), findsOneWidget);
expect(find.text('Trip 2'), findsOneWidget);
expect(find.text('Trip 3'), findsOneWidget);
// Collapsed: the summary answers "did that run go well" without opening.
expect(find.text('Joe Mathew'), findsNothing);
expect(find.byIcon(LucideIcons.chevronDown), findsNWidgets(3));
});
testWidgets('opening one trip closes the one already open', (tester) async {
await pump(
tester,
completed: [
atHour('A1', 'Joe Mathew', 9),
atHour('A2', 'Green Leaf', 14),
],
);
await tester.tap(find.text('Trip 1'));
await tester.pumpAndSettle();
expect(find.text('Joe Mathew'), findsOneWidget);
expect(find.text('Green Leaf'), findsNothing);
await tester.tap(find.text('Trip 2'));
await tester.pumpAndSettle();
expect(
find.text('Joe Mathew'),
findsNothing,
reason:
'three expanded bodies is the list the rider already could '
'not read — one at a time is the whole point',
);
expect(find.text('Green Leaf'), findsOneWidget);
// Tapping the open one closes it again.
await tester.tap(find.text('Trip 2'));
await tester.pumpAndSettle();
expect(find.text('Green Leaf'), findsNothing);
});
testWidgets('a skipped stop stays inside the trip it belonged to', (
tester,
) async {
final skip = {
...parked('A9', 'Sri Balaji Stores', 'Shutter closed'),
'skippedat': DateTime(
now.year,
now.month,
now.day,
9,
30,
).toIso8601String(),
'expected_pickup_time': DateTime(
now.year,
now.month,
now.day,
9,
30,
).toIso8601String(),
};
await pump(
tester,
completed: [atHour('A1', 'Joe Mathew', 9)],
skipped: [skip],
);
// One morning run holding both outcomes. The summary names them apart
// without opening, and neither word is an API enum.
expect(find.text('Trip 1'), findsOneWidget);
expect(find.textContaining('skipped'), findsWidgets);
expect(find.text('Sri Balaji Stores'), findsOneWidget);
expect(find.text('Joe Mathew'), findsOneWidget);
});
testWidgets('no date band, and never the word UNDATED', (tester) async {
// A record whose timestamps will not parse is a payload defect, not a
// fact about the rider's morning — and not something to print at him.
await pump(
tester,
completed: [
{...done('A1', 'Joe Mathew'), 'completedat': 'not-a-timestamp'},
],
);
expect(find.text('UNDATED'), findsNothing);
expect(find.text('Undated'), findsNothing);
expect(find.text('TODAY'), findsNothing);
expect(
find.text('Joe Mathew'),
findsOneWidget,
reason:
'the record is kept, quietly — dropping it would lose a '
"rider's completed stop over a missing field",
);
});
testWidgets('"Trip 2 had a problem", then "Arun is the one"', (
tester,
) async {
// ── The whole brief, as one test ──
//
// At arm's length, in two seconds: which trip went wrong. Then, one tap
// later: which stop is owed. If either answer needs reading rather than
// glancing, the page has failed regardless of how it photographs.
final skipAt = DateTime(now.year, now.month, now.day, 14, 40);
await pump(
tester,
completed: [
atHour('A1', 'Joe Mathew', 9),
atHour('A2', 'Sarah', 10),
atHour('A3', 'Priya Nair', 14),
atHour('A4', 'Meera', 19),
],
skipped: [
{
...parked('A5', 'Arun', 'Customer unavailable'),
'skippedat': skipAt.toIso8601String(),
'expected_pickup_time': skipAt.toIso8601String(),
},
],
);
// 1. The shift says something is owed, before any trip is opened.
expect(find.textContaining('needs attention'), findsOneWidget);
// 2. And exactly one trip admits to it — Trip 2, on its collapsed
// header, without the rider opening anything.
expect(find.text('Trip 1'), findsOneWidget);
expect(find.text('Trip 2'), findsOneWidget);
expect(find.text('Trip 3'), findsOneWidget);
expect(find.textContaining('skipped'), findsOneWidget);
expect(find.text('Arun'), findsNothing, reason: 'still collapsed');
// 3. Opening it names the stop and offers the one thing left to do.
await tester.tap(find.text('Trip 2'));
await tester.pumpAndSettle();
expect(find.text('Arun'), findsOneWidget);
expect(find.text('Customer unavailable'), findsOneWidget);
expect(find.text('Resume stop'), findsOneWidget);
// 4. And the settled stops in the same trip carry no action at all.
expect(find.text('Priya Nair'), findsOneWidget);
expect(
find.text('Resume stop'),
findsOneWidget,
reason: 'a completed stop has nothing left to do',
);
});
testWidgets('a completed row carries no booking reference', (tester) async {
// The reference is a lookup key for an office. It lives on the record
// page; a rider scanning his shift should not be reading past database
// identifiers to find a name.
await pump(
tester,
completed: [atHour('DM-BK-501CB551', 'Joe Mathew', 9)],
);
expect(find.text('Joe Mathew'), findsOneWidget);
expect(find.textContaining('DM-BK-501CB551'), findsNothing);
});
testWidgets('no internal line or type name reaches the page', (
tester,
) async {
await pump(tester, completed: [atHour('A1', 'Joe Mathew', 9)]);
for (final leak in const ['MILK', 'Milk', 'Milk Man', 'milkMan']) {
expect(find.text(leak), findsNothing, reason: leak);
}
});
});
group('states', () {
testWidgets('the skeleton is drawn, not just laid out', (tester) async {
await pump(tester, settle: false);
// `SkeletonBone` paints in `pureSurface` — the page's own colour — and is
// visible only inside the shimmer that recolours its subtree. Without the
// wrapper this state rendered as a blank white screen, which is
// indistinguishable from a load that has hung.
expect(find.byType(MilerShimmer), findsOneWidget);
expect(
find.descendant(
of: find.byType(MilerShimmer),
matching: find.byType(SkeletonBone),
),
findsWidgets,
);
});
testWidgets('the empty state is artwork and one way out', (tester) async {
await pump(tester);
// ── The words went; the picture carries it ──
//
// A glyph, a headline and a sentence under it were three statements of
// one fact — nothing finished yet — on a screen whose emptiness already
// says so. Product's call is that the artwork says it alone.
expect(find.text('No activity yet'), findsNothing);
expect(
find.text('Stops you finish today will appear here.'),
findsNothing,
);
expect(find.byIcon(LucideIcons.history), findsNothing);
// What remains is the one thing here the rider can act on.
//
// The artwork itself is asserted by the reference shot rather than by a
// finder: `Image.asset` resolves against a bundle the test binding does
// not carry, so a finder for it would be pinning the harness rather than
// the screen.
expect(find.textContaining('Go to'), findsOneWidget);
});
});
}