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>
319 lines
9.2 KiB
Dart
319 lines
9.2 KiB
Dart
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');
|
||
});
|
||
});
|
||
}
|