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>
299 lines
11 KiB
Dart
299 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'package:miler/data/miler_api.dart';
|
|
import 'package:miler/data/service_profile.dart';
|
|
import 'package:miler/views/Dashboard/pickups/shipment_capture.dart';
|
|
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// THE LOGISTICS SHIPMENT DESK
|
|
///
|
|
/// ```
|
|
/// arrived ─▶ verify ─▶ FROM ─▶ TO ─▶ weight ─▶ distance ─▶ price
|
|
/// ─▶ payment ─▶ INITIATE ORDER ─▶ picked up ─▶ hub
|
|
/// ```
|
|
///
|
|
/// ── The rule this file is really about ──
|
|
///
|
|
/// **No number is invented.** The fare comes from the pricing table against the
|
|
/// real pincodes and the real weight; when the table has no rule for a
|
|
/// combination the screen must say so and refuse, because a locally computed
|
|
/// figure that looks official on a doorstep is how a rider ends up collecting
|
|
/// the wrong money in the company's name.
|
|
///
|
|
/// And the mirror, which is the half that breaks quietly: none of this may ever
|
|
/// appear on a milk run, where the food was booked and paid for before the
|
|
/// rider's shift started.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
ServiceProfile.setActive(ServiceProfile.parcel);
|
|
});
|
|
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
|
|
group('the quote never invents a fare', () {
|
|
test('a priced combination reads the band and charges the lower end', () {
|
|
final q = ShipmentQuote.fromData({
|
|
'found': true,
|
|
'zone': 'Local',
|
|
'service_type': 'Normal',
|
|
'weight': 2.5,
|
|
'currency': 'INR',
|
|
'results': [
|
|
{
|
|
'category': 'General',
|
|
'category_label': 'General goods',
|
|
'min_price': 60,
|
|
'max_price': 90,
|
|
},
|
|
],
|
|
}, preferredCategory: 'General');
|
|
|
|
expect(q.found, isTrue);
|
|
expect(q.zone, 'Local');
|
|
expect(q.minPrice, 60);
|
|
expect(q.maxPrice, 90);
|
|
expect(q.isRange, isTrue);
|
|
expect(
|
|
q.payable,
|
|
60,
|
|
reason:
|
|
'the lower end is what the customer was quoted at booking; '
|
|
'arguing about the difference on a doorstep is the worse trade',
|
|
);
|
|
});
|
|
|
|
test('an unpriced combination is not a free shipment', () {
|
|
final q = ShipmentQuote.fromData({
|
|
'found': false,
|
|
'zone': 'National',
|
|
'service_type': 'Express',
|
|
'weight': 400,
|
|
'message': 'no pricing configured for this combination',
|
|
'results': <dynamic>[],
|
|
});
|
|
|
|
expect(q.found, isFalse);
|
|
expect(q.payable, 0);
|
|
expect(
|
|
q.message,
|
|
isNotEmpty,
|
|
reason: 'the rider must be told why, not shown a zero he might collect',
|
|
);
|
|
});
|
|
|
|
test('found:true with an empty result set is still unpriced', () {
|
|
// A malformed answer must fail the same way a clear "no" does, rather
|
|
// than falling through to a zero that reads as free.
|
|
final q = ShipmentQuote.fromData({
|
|
'found': true,
|
|
'zone': 'Local',
|
|
'results': <dynamic>[],
|
|
});
|
|
expect(q.found, isFalse);
|
|
expect(q.payable, 0);
|
|
});
|
|
|
|
test('the rider-named category wins over the cheapest row', () {
|
|
final q = ShipmentQuote.fromData({
|
|
'found': true,
|
|
'zone': 'Local',
|
|
'currency': 'INR',
|
|
'results': [
|
|
{'category': 'General', 'min_price': 50, 'max_price': 70},
|
|
{'category': 'Fragile', 'min_price': 120, 'max_price': 160},
|
|
],
|
|
}, preferredCategory: 'Fragile');
|
|
|
|
expect(
|
|
q.payable,
|
|
120,
|
|
reason:
|
|
'he said it was fragile; pricing it as general goods '
|
|
'undercharges the customer and under-insures the parcel',
|
|
);
|
|
});
|
|
|
|
test('with no category named, the cheapest row wins', () {
|
|
final q = ShipmentQuote.fromData({
|
|
'found': true,
|
|
'zone': 'Local',
|
|
'results': [
|
|
{'category': 'Fragile', 'min_price': 120, 'max_price': 160},
|
|
{'category': 'General', 'min_price': 50, 'max_price': 70},
|
|
],
|
|
});
|
|
expect(q.payable, 50);
|
|
});
|
|
|
|
test('string prices off the wire still parse', () {
|
|
final q = ShipmentQuote.fromData({
|
|
'found': true,
|
|
'zone': 'Regional',
|
|
'results': [
|
|
{'category': 'General', 'min_price': '80.50', 'max_price': '110'},
|
|
],
|
|
});
|
|
expect(q.minPrice, 80.5);
|
|
expect(q.maxPrice, 110);
|
|
});
|
|
});
|
|
|
|
group('the pricing contract', () {
|
|
test('the service tiers are the pricing table\'s, not the booking\'s', () {
|
|
// `bookingserviceoptions` uses Normal/Fast/Superfast; the pricing table
|
|
// uses Normal/Express, and sending the wrong vocabulary is a 400.
|
|
expect(MilerApi.serviceTypes, ['Normal', 'Express']);
|
|
});
|
|
|
|
test('the categories match what the handler will accept', () {
|
|
expect(MilerApi.parcelCategories, contains('General'));
|
|
expect(MilerApi.parcelCategories, contains('Fragile'));
|
|
expect(MilerApi.parcelCategories, contains('Documents'));
|
|
});
|
|
});
|
|
|
|
group('the shipment desk on screen', () {
|
|
Future<void> pumpDesk(
|
|
WidgetTester tester, {
|
|
Map<String, dynamic>? booking,
|
|
}) async {
|
|
tester.view.physicalSize = const Size(1080, 2400);
|
|
tester.view.devicePixelRatio = 3.0;
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
|
|
await tester.pumpWidget(
|
|
ScreenUtilInit(
|
|
designSize: const Size(390, 844),
|
|
builder: (_, _) => MaterialApp(
|
|
home: ShipmentCapturePage(
|
|
pickup:
|
|
booking ??
|
|
{
|
|
'orderid': '2001',
|
|
'pickupid': 2001,
|
|
'pickupaddress': '9 Mettupalayam Road',
|
|
'pickuppincode': '641002',
|
|
'dropaddress': '12 Cross Cut Road',
|
|
'droppincode': '641012',
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
// The desk takes a GPS fix on open so the FROM pin is where the rider is
|
|
// standing. There is no platform behind the channel here, so that call
|
|
// never answers and its timeout is left holding a Timer the binding
|
|
// rightly complains about at teardown. Running the clock past the limit
|
|
// lets the guard fire and the widget fall back to the booking's own
|
|
// coordinates — which is exactly the path a rider with no signal takes.
|
|
await tester.pump(const Duration(seconds: 10));
|
|
}
|
|
|
|
testWidgets('asks for FROM, TO, weight, distance and price', (
|
|
tester,
|
|
) async {
|
|
await pumpDesk(tester);
|
|
|
|
// The two ends of the journey, now named on one card with a rail
|
|
// between them rather than as two headed groups of loose fields.
|
|
expect(find.text('FROM'), findsOneWidget);
|
|
expect(find.text('TO'), findsOneWidget);
|
|
expect(find.text('THE PARCEL'), findsOneWidget);
|
|
expect(find.text('WHAT THE CUSTOMER PAYS'), findsOneWidget);
|
|
expect(find.text('Pickup address'), findsOneWidget);
|
|
expect(find.text('Delivery address'), findsOneWidget);
|
|
expect(find.text('Weight (kg)'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('seeds both addresses so the rider confirms, not transcribes', (
|
|
tester,
|
|
) async {
|
|
await pumpDesk(tester);
|
|
expect(find.text('9 Mettupalayam Road'), findsOneWidget);
|
|
expect(find.text('12 Cross Cut Road'), findsOneWidget);
|
|
expect(find.text('641002'), findsOneWidget);
|
|
expect(find.text('641012'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('will not continue without a price', (tester) async {
|
|
await pumpDesk(tester);
|
|
|
|
// No weight typed, so no quote has been asked for and none can exist.
|
|
expect(find.text('Weigh the parcel'), findsOneWidget);
|
|
|
|
final button = tester.widget<ElevatedButton>(
|
|
find.descendant(
|
|
of: find.byType(ShipmentCapturePage),
|
|
matching: find.byType(ElevatedButton),
|
|
),
|
|
);
|
|
expect(
|
|
button.onPressed,
|
|
isNull,
|
|
reason: 'the desk must not hand back a shipment it could not price',
|
|
);
|
|
});
|
|
|
|
testWidgets('names what is missing rather than just greying out', (
|
|
tester,
|
|
) async {
|
|
await pumpDesk(tester, booking: {'orderid': '2002', 'pickupid': 2002});
|
|
// A disabled button with no reason is the thing riders phone the hub
|
|
// about.
|
|
expect(find.text('Confirm the pickup address'), findsOneWidget);
|
|
});
|
|
});
|
|
|
|
group('none of this reaches a milk run', () {
|
|
test('the capabilities are all off', () {
|
|
const p = ServiceProfile.milkMan;
|
|
expect(p.capturesShipmentAddresses, isFalse);
|
|
expect(p.pricesShipment, isFalse);
|
|
expect(p.initiatesShipment, isFalse);
|
|
expect(p.collectsCash, isFalse);
|
|
expect(p.needsVerification, isFalse);
|
|
});
|
|
|
|
test('so nothing is ever owed at a milk-run door', () {
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
// Even a booking that arrives carrying an amount reads as zero: the food
|
|
// was paid for by subscription and the rider must not ask for money.
|
|
expect(stopCollectionAmount({'collectionamt': 450}), 0);
|
|
|
|
ServiceProfile.setActive(ServiceProfile.parcel);
|
|
expect(stopCollectionAmount({'collectionamt': 450}), 450);
|
|
});
|
|
|
|
test('and the milk run has no kitchen collection on logistics', () {
|
|
ServiceProfile.setActive(ServiceProfile.parcel);
|
|
expect(ServiceProfile.active.sourceIsKitchen, isFalse);
|
|
expect(ServiceProfile.active.bulkLoadProof, isFalse);
|
|
});
|
|
});
|
|
|
|
group('the backend dependencies are declared, not hidden', () {
|
|
test('the quoted price has nowhere to be recorded, and says so', () {
|
|
expect(
|
|
MilerApi.missingFromBackend.any((g) => g.contains('quoted price')),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('the delivery OTP cannot be verified, and says so', () {
|
|
expect(
|
|
MilerApi.missingFromBackend.any((g) => g.contains('delivery OTP')),
|
|
isTrue,
|
|
);
|
|
});
|
|
});
|
|
}
|