Files
doormile_milderapp/test/route_brief_test.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

319 lines
9.2 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:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/Dashboard/home/route_brief.dart';
void main() {
// Two stops roughly 2.5 km and 3.5 km from a Coimbatore origin.
final stops = <String, Map<String, dynamic>>{
'A': {
'Quantity': '3',
'weight': '4.5',
'collectionamt': '250',
'pickuplat': '11.0243',
'pickuplon': '76.9558',
'kms': '2.1',
},
'B': {
'Quantity': '8',
'weight': '2',
'collectionamt': '0',
'pickuplat': '11.0312',
'pickuplon': '76.9678',
'kms': '1.5',
},
};
group('RouteBrief load & capacity', () {
test('sums parcels, weight and cash across the route', () {
final brief = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
);
expect(brief.stops, 2);
expect(brief.parcels, 11);
expect(brief.weightKg, 6.5);
expect(brief.collectAmount, 250);
});
test('counts accepted stops that already left the pending list', () {
final brief = RouteBrief.from(
orderIds: const ['A'],
ordersMap: stops,
shiftStart: '',
shiftEnd: '',
extraStops: [stops['B']!],
);
// Same totals as if both were still pending — an accepted stop is still
// a stop the rider has to ride to.
expect(brief.stops, 2);
expect(brief.parcels, 11);
expect(brief.weightKg, 6.5);
});
test('strips units baked into numeric strings', () {
final brief = RouteBrief.from(
orderIds: const ['A'],
ordersMap: {
'A': {'Quantity': '2', 'weight': '3.25 kg', 'collectionamt': '₹180'},
},
shiftStart: '',
shiftEnd: '',
);
expect(brief.weightKg, 3.25);
expect(brief.collectAmount, 180);
});
});
group('RouteBrief round-trip distance', () {
test('closes the loop back to the origin', () {
final withOrigin = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '',
shiftEnd: '',
riderLat: 11.0180,
riderLng: 76.9580,
);
final withoutOrigin = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '',
shiftEnd: '',
);
// Origin → A → B → origin must be longer than the bare A → B leg.
expect(withOrigin.routeKm, greaterThan(withoutOrigin.routeKm));
expect(withOrigin.routeKm, greaterThan(4));
expect(withOrigin.routeKm, lessThan(15));
});
test(
'falls back to the per-stop kms field when coordinates are missing',
() {
final brief = RouteBrief.from(
orderIds: const ['A'],
ordersMap: {
'A': {'Quantity': '1', 'kms': '4.2'},
},
shiftStart: '',
shiftEnd: '',
);
expect(brief.routeKm, 4.2);
},
);
});
group('RouteBrief shift window', () {
test('flags a missing shift', () {
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '',
shiftEnd: '',
);
expect(brief.shiftSet, isFalse);
expect(brief.timeLeft, isNull);
expect(brief.shiftWindowLabel, 'Shift not set');
});
test('flags a half-set shift as not set', () {
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '09:00:00',
shiftEnd: '',
);
expect(brief.shiftSet, isFalse);
});
test('computes time left and a per-stop budget', () {
final now = DateTime(2026, 7, 27, 15, 0);
final brief = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: now,
);
expect(brief.shiftSet, isTrue);
expect(brief.timeLeft, const Duration(hours: 3));
expect(brief.perStopBudget, const Duration(minutes: 90));
expect(brief.isPaceAtRisk, isFalse);
expect(brief.shiftWindowLabel, '9:00 AM – 6:00 PM');
});
test('clamps a finished shift to zero instead of going negative', () {
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: DateTime(2026, 7, 27, 20, 0),
);
expect(brief.timeLeft, Duration.zero);
expect(brief.perStopBudget, isNull);
});
test('rolls an overnight shift end into the next day', () {
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '22:00:00',
shiftEnd: '06:00:00',
now: DateTime(2026, 7, 27, 23, 0),
);
expect(brief.timeLeft, const Duration(hours: 7));
});
test('raises the pace flag when stops cannot fit in the time left', () {
final brief = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: DateTime(2026, 7, 27, 17, 51), // 9 minutes for 2 stops
);
expect(brief.isPaceAtRisk, isTrue);
});
test('parses 12-hour shift strings', () {
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '9:30 AM',
shiftEnd: '6:00 PM',
now: DateTime(2026, 7, 27, 12, 0),
);
expect(brief.shiftSet, isTrue);
expect(brief.timeLeft, const Duration(hours: 6));
});
});
/// A missing shift is somebody else's admin: the rider cannot fix it, did not
/// cause it, and is not blocked by it — the trips still load and he can still
/// ride them. So the banner says nothing at all rather than parking a
/// permanent amber "No shift set for today" box at the top of his first
/// screen. These tests keep it that way, because a box like that comes back
/// easily and always with good intentions.
group('ShiftBanner', () {
Future<void> pump(WidgetTester tester, RouteBrief brief) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(body: ShiftBanner(brief: brief)),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('renders nothing at all when no shift is set', (tester) async {
await pump(
tester,
RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '',
shiftEnd: '',
),
);
expect(tester.takeException(), isNull);
expect(find.text('No shift set for today'), findsNothing);
expect(find.text('Retry'), findsNothing);
expect(find.textContaining('hub manager'), findsNothing);
// Not merely empty of those strings — the widget takes no height, so the
// route below it starts at the top of the page.
expect(tester.getSize(find.byType(ShiftBanner)).height, 0);
});
testWidgets('still shows the window once the hub sets one', (tester) async {
await pump(
tester,
RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: DateTime(2026, 7, 27, 15, 0),
),
);
expect(tester.takeException(), isNull);
expect(find.text('3h left'), findsOneWidget);
expect(tester.getSize(find.byType(ShiftBanner)).height, greaterThan(0));
});
});
group('RouteBrief time windows', () {
final now = DateTime(2026, 7, 27, 15, 0);
test('counts stops past their slot and stops closing soon', () {
final brief = RouteBrief.from(
orderIds: const ['late', 'soon', 'later'],
ordersMap: {
'late': {
'expected_pickup_time': now
.subtract(const Duration(minutes: 20))
.toIso8601String(),
},
'soon': {
'expected_pickup_time': now
.add(const Duration(minutes: 10))
.toIso8601String(),
},
'later': {
'expected_pickup_time': now
.add(const Duration(hours: 3))
.toIso8601String(),
},
},
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: now,
);
expect(brief.overdueStops, 1);
expect(brief.dueSoonStops, 1);
});
test('ignores stops with no slot time', () {
final brief = RouteBrief.from(
orderIds: const ['A', 'B'],
ordersMap: stops,
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
now: now,
);
expect(brief.overdueStops, 0);
expect(brief.dueSoonStops, 0);
});
});
group('formatDuration', () {
test('reads like a clock', () {
expect(formatDuration(const Duration(minutes: 45)), '45m');
expect(formatDuration(const Duration(hours: 2, minutes: 15)), '2h 15m');
expect(formatDuration(const Duration(hours: 3)), '3h');
expect(formatDuration(const Duration(seconds: 20)), 'under a minute');
});
});
}