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>
255 lines
8.0 KiB
Dart
255 lines
8.0 KiB
Dart
import 'dart:async';
|
|
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/miler_api.dart';
|
|
import 'package:miler/data/mock_backend.dart';
|
|
import 'package:miler/data/mutation_guard.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// ONE PRESS, ONE REQUEST
|
|
///
|
|
/// Every business-critical action here is a POST the backend is not idempotent
|
|
/// about. A rider presses them on a moving bike, with gloves, on a screen he
|
|
/// cannot look at for long — so a double tap is not an edge case. Two
|
|
/// `duty/start` calls are a server error; two `payment` calls are two payments;
|
|
/// two `accept` calls race each other to decide what the list shows.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late int calls;
|
|
late Completer<void> release;
|
|
|
|
/// A server that does not answer until the test lets it, so a second press
|
|
/// genuinely lands while the first is still in flight.
|
|
void stubSlow({int code = 200, Map<String, dynamic>? body}) {
|
|
calls = 0;
|
|
release = Completer<void>();
|
|
MilerApi.client = MockClient((_) async {
|
|
calls++;
|
|
await release.future;
|
|
return http.Response(
|
|
json.encode(body ?? {'success': true}),
|
|
code,
|
|
headers: const {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
}
|
|
|
|
setUp(() async {
|
|
MockBackend.enabled = false;
|
|
MutationGuard.reset();
|
|
SharedPreferences.setMockInitialValues({'authtoken': 'test-token'});
|
|
await ApiConfig.setToken('test-token');
|
|
});
|
|
|
|
tearDown(() {
|
|
MilerApi.client = http.Client();
|
|
MockBackend.enabled = kMockBackend;
|
|
MutationGuard.reset();
|
|
MilerApi.onUnauthorized = null;
|
|
});
|
|
|
|
test('a double tap on accept sends one request', () async {
|
|
stubSlow();
|
|
|
|
final first = MilerApi.acceptAssignment(1042);
|
|
final second = MilerApi.acceptAssignment(1042);
|
|
|
|
release.complete();
|
|
final results = await Future.wait([first, second]);
|
|
|
|
expect(calls, 1, reason: 'the second press must not reach the server');
|
|
expect(results.where((r) => r.ok).length, 1);
|
|
|
|
// ── The dropped press is a failure, not a silent null ──
|
|
//
|
|
// A caller that does not know about the guard still has to take its error
|
|
// path rather than reading nothing as success.
|
|
final dropped = results.firstWhere((r) => !r.ok);
|
|
expect(dropped.message, contains('already going through'));
|
|
});
|
|
|
|
test('two different assignments are not blocked by each other', () async {
|
|
stubSlow();
|
|
|
|
final a = MilerApi.acceptAssignment(1);
|
|
final b = MilerApi.acceptAssignment(2);
|
|
release.complete();
|
|
await Future.wait([a, b]);
|
|
|
|
expect(calls, 2, reason: 'the guard is keyed by resource, not by verb');
|
|
});
|
|
|
|
test(
|
|
'the key frees on completion, so a genuine retry goes through',
|
|
() async {
|
|
stubSlow();
|
|
final first = MilerApi.acceptAssignment(7);
|
|
release.complete();
|
|
await first;
|
|
|
|
stubSlow();
|
|
final retry = MilerApi.acceptAssignment(7);
|
|
release.complete();
|
|
await retry;
|
|
|
|
expect(calls, 1, reason: 'the second stub saw a fresh request');
|
|
},
|
|
);
|
|
|
|
test('a failed mutation frees its key too', () async {
|
|
// A guard that leaks on failure is worse than no guard: the rider's retry
|
|
// would be dropped for the rest of the session.
|
|
MilerApi.client = MockClient((_) async => throw const _Dead());
|
|
await MilerApi.pickupComplete(55);
|
|
expect(MutationGuard.isBusy('pickup-complete:55'), isFalse);
|
|
});
|
|
|
|
test('duty start and end share one key, in both directions', () async {
|
|
stubSlow();
|
|
final start = MilerApi.startDuty(lat: 11, lon: 76);
|
|
final end = MilerApi.endDuty();
|
|
release.complete();
|
|
await Future.wait([start, end]);
|
|
|
|
expect(
|
|
calls,
|
|
1,
|
|
reason:
|
|
'a rider tapping the switch again because the first tap "did '
|
|
'nothing" is the commonest way to double-fire duty',
|
|
);
|
|
});
|
|
|
|
group('every non-idempotent mutation is guarded', () {
|
|
Future<void> expectGuarded(
|
|
String label,
|
|
Future<dynamic> Function() call,
|
|
) async {
|
|
stubSlow();
|
|
final a = call();
|
|
final b = call();
|
|
release.complete();
|
|
await Future.wait([a, b]);
|
|
expect(calls, 1, reason: '$label is not protected against a double tap');
|
|
}
|
|
|
|
test('the pickup flow', () async {
|
|
await expectGuarded('reached', () => MilerApi.reached(9));
|
|
await expectGuarded('parcel', () => MilerApi.submitParcels(9, const []));
|
|
await expectGuarded(
|
|
'payment',
|
|
() => MilerApi.submitPayment(9, paymentMode: 'Cash', amount: 120),
|
|
);
|
|
await expectGuarded('pickup-complete', () => MilerApi.pickupComplete(9));
|
|
});
|
|
|
|
test('the delivery flow', () async {
|
|
await expectGuarded(
|
|
'deliver',
|
|
() => MilerApi.deliver(3, deliveredToName: 'Joe'),
|
|
);
|
|
await expectGuarded(
|
|
'skip',
|
|
() => MilerApi.skipConsignment(3, reason: 'Nobody home'),
|
|
);
|
|
});
|
|
|
|
test('availability and breaks', () async {
|
|
await expectGuarded(
|
|
'availability',
|
|
() => MilerApi.setAvailability('Available'),
|
|
);
|
|
await expectGuarded('break', () => MilerApi.startBreak('Personal'));
|
|
});
|
|
|
|
test('reject and cancel', () async {
|
|
await expectGuarded(
|
|
'reject',
|
|
() => MilerApi.rejectAssignment(4, reason: 'Too far'),
|
|
);
|
|
await expectGuarded(
|
|
'cancel',
|
|
() => MilerApi.cancelBooking(4, reason: 'Shop closed'),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('a 401 ends the session, once, centrally', () {
|
|
test('the dead token is dropped and the app is told', () async {
|
|
var told = 0;
|
|
MilerApi.onUnauthorized = () => told++;
|
|
MilerApi.client = MockClient(
|
|
(_) async => http.Response(
|
|
json.encode({'success': false, 'message': 'token expired'}),
|
|
401,
|
|
headers: const {'content-type': 'application/json'},
|
|
),
|
|
);
|
|
|
|
final res = await MilerApi.getProfile();
|
|
|
|
expect(res.ok, isFalse);
|
|
expect(res.status, 401);
|
|
expect(told, 1);
|
|
expect(
|
|
await ApiConfig.getToken(),
|
|
isNull,
|
|
reason: 'nothing may retry with a token the server has refused',
|
|
);
|
|
});
|
|
|
|
test('a wrong PIN does not log anybody out', () async {
|
|
var told = 0;
|
|
MilerApi.onUnauthorized = () => told++;
|
|
MilerApi.client = MockClient(
|
|
(_) async => http.Response(
|
|
json.encode({'success': false, 'message': 'incorrect PIN'}),
|
|
401,
|
|
headers: const {'content-type': 'application/json'},
|
|
),
|
|
);
|
|
|
|
await MilerApi.verifyPin(phone: '9787698259', pin: '0000');
|
|
|
|
expect(
|
|
told,
|
|
0,
|
|
reason:
|
|
'a 401 on an auth route is about a credential, not an expired '
|
|
'session — clearing state here is a logout in response to a typo',
|
|
);
|
|
});
|
|
|
|
test('a 429 is a failure, not a session end', () async {
|
|
var told = 0;
|
|
MilerApi.onUnauthorized = () => told++;
|
|
MilerApi.client = MockClient(
|
|
(_) async => http.Response(
|
|
json.encode({'success': false, 'message': 'rate limited'}),
|
|
429,
|
|
headers: const {'content-type': 'application/json'},
|
|
),
|
|
);
|
|
|
|
final res = await MilerApi.assignments();
|
|
expect(res.ok, isFalse);
|
|
expect(res.status, 429);
|
|
expect(res.message, 'rate limited');
|
|
expect(told, 0);
|
|
expect(await ApiConfig.getToken(), isNotNull);
|
|
});
|
|
});
|
|
}
|
|
|
|
class _Dead implements Exception {
|
|
const _Dead();
|
|
}
|