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>
This commit is contained in:
239
test/delivery_modes_test.dart
Normal file
239
test/delivery_modes_test.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/consignment_state.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/data/mock_backend.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/pickups.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// TWO BACKENDS, ONE BUILD
|
||||
///
|
||||
/// `Collected_By_Miler` and `start-delivery` ship behind a server-side flag
|
||||
/// that is **off in production today**, so both lifecycles are live at once:
|
||||
///
|
||||
/// old `pickup-complete` releases hyperlocal work itself. The consignment
|
||||
/// is already `Out_for_Delivery` when it reaches the Deliveries tab and
|
||||
/// there is nothing for **Start delivery** to call.
|
||||
/// new the pivot stops at `Collected_By_Miler` and the press is what puts
|
||||
/// the load on the road.
|
||||
///
|
||||
/// The app must be right in both **without mirroring the flag**, because a
|
||||
/// mirrored flag is a second source of truth that can be wrong — and would be
|
||||
/// wrong for every rider for however long it took to ship an app release after
|
||||
/// the server toggled. So the behaviour is inferred from what the data says:
|
||||
/// `consignmentstatus` on the row, then the consignment itself, then the
|
||||
/// `can_start_delivery` / `can_deliver` permissions.
|
||||
///
|
||||
/// These pin both modes, and pin that the old one costs no request.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late List<http.Request> sent;
|
||||
|
||||
http.Response jsonRes(Object body, [int code = 200]) => http.Response(
|
||||
json.encode(body),
|
||||
code,
|
||||
headers: const {'content-type': 'application/json'},
|
||||
);
|
||||
|
||||
void stubRoutes(Map<String, http.Response> routes) {
|
||||
sent = <http.Request>[];
|
||||
MilerApi.client = MockClient((req) async {
|
||||
sent.add(req);
|
||||
for (final e in routes.entries) {
|
||||
if (req.url.path.endsWith(e.key)) return e.value;
|
||||
}
|
||||
return jsonRes({'success': true});
|
||||
});
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
MockBackend.enabled = false;
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'userid': 38,
|
||||
'authtoken': 'test-token',
|
||||
});
|
||||
await ApiConfig.setToken('test-token');
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
stubRoutes(const {});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
MilerApi.client = http.Client();
|
||||
MockBackend.enabled = kMockBackend;
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
});
|
||||
|
||||
Map<String, dynamic> stop({String? consignmentStatus}) => {
|
||||
'orderid': 'ORDER-1',
|
||||
'pickupid': 75,
|
||||
'consignmentid': 46,
|
||||
if (consignmentStatus != null) 'consignmentstatus': consignmentStatus,
|
||||
'orderstatus': 'picked',
|
||||
};
|
||||
|
||||
group('old backend — the pivot already released the load', () {
|
||||
test('the press costs no request at all', () async {
|
||||
stubRoutes(const {});
|
||||
final released = await releaseForDelivery(
|
||||
stop(consignmentStatus: 'Out_for_Delivery'),
|
||||
);
|
||||
|
||||
expect(released, isTrue);
|
||||
expect(
|
||||
sent,
|
||||
isEmpty,
|
||||
reason:
|
||||
'a start-delivery the server can only refuse is not a harmless '
|
||||
'no-op — it is a 4xx per rider press in the backend\'s log',
|
||||
);
|
||||
});
|
||||
|
||||
test('a stop already delivered elsewhere is not re-released', () async {
|
||||
stubRoutes(const {});
|
||||
expect(
|
||||
await releaseForDelivery(stop(consignmentStatus: 'Delivered')),
|
||||
isTrue,
|
||||
);
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('new backend — the rider makes the release', () {
|
||||
test('a collected consignment is started on the server', () async {
|
||||
stubRoutes({
|
||||
'/start-delivery': jsonRes({
|
||||
'success': true,
|
||||
'data': {'status': 'Out_for_Delivery'},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
await releaseForDelivery(stop(consignmentStatus: 'Collected_By_Miler')),
|
||||
isTrue,
|
||||
);
|
||||
expect(sent.single.method, 'POST');
|
||||
expect(
|
||||
sent.single.url.path.endsWith('/miler/consignments/46/start-delivery'),
|
||||
isTrue,
|
||||
);
|
||||
expect(sent.single.headers['Idempotency-Key'], isNotNull);
|
||||
});
|
||||
|
||||
test('a refusal is answered by the consignment, not assumed', () async {
|
||||
// `INVALID_STATE` means "not Collected_By_Miler" — which covers both a
|
||||
// real refusal and a consignment another device already released.
|
||||
stubRoutes({
|
||||
'/start-delivery': jsonRes({
|
||||
'success': false,
|
||||
'code': 'INVALID_STATE',
|
||||
'message': 'consignment is not collected',
|
||||
}, 400),
|
||||
'/miler/consignments/46': jsonRes({
|
||||
'success': true,
|
||||
'data': {'status': 'Out_for_Delivery'},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
await releaseForDelivery(stop(consignmentStatus: 'Collected_By_Miler')),
|
||||
isTrue,
|
||||
reason: 'already on the road is not a reason to block the rider',
|
||||
);
|
||||
});
|
||||
|
||||
test('a genuine refusal does not fake a release', () async {
|
||||
stubRoutes({
|
||||
'/start-delivery': jsonRes({
|
||||
'success': false,
|
||||
'code': 'INVALID_STATE',
|
||||
'message': 'consignment is at the hub',
|
||||
}, 400),
|
||||
'/miler/consignments/46': jsonRes({
|
||||
'success': true,
|
||||
'data': {'status': 'Inwarded_at_Hub'},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
await releaseForDelivery(stop(consignmentStatus: 'Collected_By_Miler')),
|
||||
isFalse,
|
||||
reason:
|
||||
'showing ACTIVE for a consignment the hub still holds is the '
|
||||
'fiction that made deliver fail at the door with nothing on '
|
||||
'screen to explain it',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('a row that says nothing falls back to asking', () {
|
||||
test('the consignment is read when the row carries no status', () async {
|
||||
stubRoutes({
|
||||
'/miler/consignments/46': jsonRes({
|
||||
'success': true,
|
||||
'data': {'status': 'Out_for_Delivery'},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(await releaseForDelivery(stop()), isTrue);
|
||||
expect(sent.single.method, 'GET');
|
||||
expect(sent.single.url.path.endsWith('/miler/consignments/46'), isTrue);
|
||||
});
|
||||
|
||||
test('the permission flags answer when the word does not', () {
|
||||
// A status this build has never heard of, plus the server's own verdict
|
||||
// on what may be done with it.
|
||||
expect(
|
||||
ConsignmentGate.stateFromDetailForTest({
|
||||
'status': 'Handed_To_Locker_Bank',
|
||||
'can_deliver': true,
|
||||
}),
|
||||
ConsignmentState.outForDelivery,
|
||||
);
|
||||
expect(
|
||||
ConsignmentGate.stateFromDetailForTest({
|
||||
'status': 'Awaiting_Rider_Departure',
|
||||
'can_start_delivery': true,
|
||||
}),
|
||||
ConsignmentState.collectedByMiler,
|
||||
);
|
||||
});
|
||||
|
||||
test('no consignment id is not a release', () async {
|
||||
expect(await releaseForDelivery({'orderid': 'ORDER-9'}), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('a skipped stop and the server agree about whether it is open', () {
|
||||
test('a consignment the hub still calls out-for-delivery stays open', () {
|
||||
expect(ConsignmentState.outForDelivery.isOpenAfterSkip, isTrue);
|
||||
expect(ConsignmentState.collectedByMiler.isOpenAfterSkip, isTrue);
|
||||
});
|
||||
|
||||
test('a consignment the hub closed is closed', () {
|
||||
expect(ConsignmentState.delivered.isOpenAfterSkip, isFalse);
|
||||
expect(ConsignmentState.rtoInitiated.isOpenAfterSkip, isFalse);
|
||||
expect(ConsignmentState.returnedToSender.isOpenAfterSkip, isFalse);
|
||||
expect(ConsignmentState.cancelled.isOpenAfterSkip, isFalse);
|
||||
});
|
||||
|
||||
test('a read that failed is not permission to close a stop', () {
|
||||
expect(
|
||||
ConsignmentState.unknown.isOpenAfterSkip,
|
||||
isTrue,
|
||||
reason:
|
||||
'a terminal local record over a consignment the hub still '
|
||||
'calls open is two systems disagreeing about whose problem a '
|
||||
'parcel is, with only the rider\'s screen saying "nobody\'s"',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user