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>
290 lines
10 KiB
Dart
290 lines
10 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/load_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/data/work_repository.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// ONE DAY'S WORK, FETCHED ONCE
|
|
///
|
|
/// Home, Deliveries and Activity asked the same question independently: three
|
|
/// timers, three copies, three ideas of what had been accepted. Switching tabs
|
|
/// re-fetched; a rebuild re-fetched; accepting a stop on one screen left the
|
|
/// other two showing the old answer until their own poll came round.
|
|
///
|
|
/// These pin the three properties that make one copy safe to share.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
final repo = WorkRepository.instance;
|
|
late int requests;
|
|
|
|
Map<String, dynamic> booking(String id) => {
|
|
'bookingid': id,
|
|
'orderid': id,
|
|
'pickupcustomer': 'Stop $id',
|
|
'pickupaddress': '12 Main Rd, Coimbatore',
|
|
'status': 'Miler_Assigned',
|
|
};
|
|
|
|
void stub({
|
|
List<Map<String, dynamic>>? rows,
|
|
Object? rawData,
|
|
int code = 200,
|
|
Object? error,
|
|
Duration delay = Duration.zero,
|
|
}) {
|
|
requests = 0;
|
|
MilerApi.client = MockClient((req) async {
|
|
// ── Only the day's own request is counted ──
|
|
//
|
|
// The repository also asks `/miler/assignments` for the hub's stop
|
|
// sequence (see `WorkRepository._stampSequence`). That is a different
|
|
// endpoint answering a different question, and counting it here would
|
|
// make the dedupe assertions below read 2 for one load — so the stub
|
|
// answers it with an empty list and the counter stays on the property
|
|
// these tests are actually about: **one `/miler/bookings` per load**.
|
|
if (req.url.path.endsWith('/miler/assignments')) {
|
|
return http.Response(
|
|
json.encode({'success': true, 'data': const []}),
|
|
200,
|
|
headers: const {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
requests++;
|
|
if (delay > Duration.zero) await Future.delayed(delay);
|
|
if (error != null) throw error;
|
|
return http.Response(
|
|
json.encode({
|
|
'success': code < 400,
|
|
'data': rawData ?? rows ?? const [],
|
|
}),
|
|
code,
|
|
headers: const {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
}
|
|
|
|
setUp(() async {
|
|
MockBackend.enabled = false;
|
|
ServiceProfile.setActive(ServiceProfile.parcel);
|
|
SharedPreferences.setMockInitialValues({'authtoken': 't'});
|
|
await ApiConfig.setToken('t');
|
|
repo.resetForTest();
|
|
stub();
|
|
});
|
|
|
|
tearDown(() {
|
|
MilerApi.client = http.Client();
|
|
MockBackend.enabled = kMockBackend;
|
|
ServiceProfile.setActive(ServiceProfile.parcel);
|
|
repo.resetForTest();
|
|
});
|
|
|
|
group('one request, however many askers', () {
|
|
test('three screens mounting together produce one request', () async {
|
|
stub(rows: [booking('A')], delay: const Duration(milliseconds: 20));
|
|
|
|
final results = await Future.wait([
|
|
repo.load(),
|
|
repo.load(),
|
|
repo.load(),
|
|
]);
|
|
|
|
expect(requests, 1, reason: 'the 2nd and 3rd await the same future');
|
|
for (final r in results) {
|
|
expect(r, isA<LoadData<List<Map<String, dynamic>>>>());
|
|
}
|
|
});
|
|
|
|
test(
|
|
'a tab switch inside the freshness window does not re-fetch',
|
|
() async {
|
|
stub(rows: [booking('A')]);
|
|
await repo.load();
|
|
await repo.load();
|
|
await repo.load();
|
|
expect(requests, 1);
|
|
},
|
|
);
|
|
|
|
test('but a pull-to-refresh always asks', () async {
|
|
stub(rows: [booking('A')]);
|
|
await repo.load();
|
|
await repo.load(force: true);
|
|
expect(requests, 2);
|
|
});
|
|
|
|
test('a mutation invalidates, so the next read is authoritative', () async {
|
|
stub(rows: [booking('A')]);
|
|
await repo.load();
|
|
await repo.invalidate();
|
|
expect(requests, 2);
|
|
});
|
|
});
|
|
|
|
// ── The classic pull-to-refresh regression ──
|
|
//
|
|
// A slow earlier request finishing after a fast later one used to overwrite
|
|
// it, and the list visibly went backwards.
|
|
test('a late response cannot overwrite a newer one', () async {
|
|
var call = 0;
|
|
final gate = Completer<void>();
|
|
MilerApi.client = MockClient((req) async {
|
|
if (req.url.path.endsWith('/miler/assignments')) {
|
|
return http.Response(
|
|
json.encode({'success': true, 'data': const []}),
|
|
200,
|
|
headers: const {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
call++;
|
|
final isFirst = call == 1;
|
|
if (isFirst) await gate.future; // the slow one
|
|
return http.Response(
|
|
json.encode({
|
|
'success': true,
|
|
'data': [booking(isFirst ? 'STALE' : 'FRESH')],
|
|
}),
|
|
200,
|
|
headers: const {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
|
|
// Two forced loads: a pull-to-refresh does not join a request that started
|
|
// before the gesture, so this really is two sequences racing.
|
|
final slow = repo.load(force: true);
|
|
final fast = repo.load(force: true);
|
|
|
|
gate.complete();
|
|
await Future.wait([slow, fast]);
|
|
|
|
final value = repo.state.valueOrNull;
|
|
expect(value, isNotNull);
|
|
expect(
|
|
value!.single['orderid'],
|
|
'FRESH',
|
|
reason: 'the slow response landed last and must have been dropped',
|
|
);
|
|
});
|
|
|
|
group('the three empties are three answers', () {
|
|
test('nothing today is empty, not a failure', () async {
|
|
stub(rows: const []);
|
|
final s = await repo.load();
|
|
expect(s, isA<LoadEmpty<List<Map<String, dynamic>>>>());
|
|
});
|
|
|
|
test('a milk-man rider is asked for, not written off', () async {
|
|
// The regression this replaces: the milk-man line was believed to have no
|
|
// endpoint, so this returned [LoadUnavailable] after **zero** requests —
|
|
// and a rider on tenant 13 whom the hub had assigned a booking saw an
|
|
// empty Home, an empty Bookings tab and an empty Activity, with no error
|
|
// anywhere to explain it. The hub dispatches that line through the same
|
|
// bookings the console assigns, so the request has to be made.
|
|
stub(rows: [booking('MILK-1')]);
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
repo.resetForTest();
|
|
|
|
final s = await repo.load();
|
|
expect(s, isA<LoadData<List<Map<String, dynamic>>>>());
|
|
expect(s.valueOrNull?.single['orderid'], 'MILK-1');
|
|
expect(requests, 1, reason: 'his work has to be asked for to arrive');
|
|
});
|
|
|
|
test('a dead connection is offline, and retryable', () async {
|
|
stub(error: const _Dead());
|
|
final s = await repo.load() as LoadFailure<List<Map<String, dynamic>>>;
|
|
expect(s.kind, LoadFailureKind.offline);
|
|
expect(s.kind.isRetryable, isTrue);
|
|
});
|
|
|
|
test(
|
|
'a 401 is unauthorized, and offering a retry would be a lie',
|
|
() async {
|
|
stub(code: 401);
|
|
final s = await repo.load() as LoadFailure<List<Map<String, dynamic>>>;
|
|
expect(s.kind, LoadFailureKind.unauthorized);
|
|
expect(s.kind.isRetryable, isFalse);
|
|
},
|
|
);
|
|
|
|
test('a 429 is rate limited, not a server fault', () async {
|
|
stub(code: 429);
|
|
final s = await repo.load() as LoadFailure<List<Map<String, dynamic>>>;
|
|
expect(s.kind, LoadFailureKind.rateLimited);
|
|
});
|
|
|
|
test(
|
|
'rows that arrive and do not map are a fault, not an empty day',
|
|
() async {
|
|
// A payload whose rows are not objects at all. The adapter maps every
|
|
// Map it is given, so this is what a genuine shape mismatch looks like —
|
|
// and it renders as "no work today" if you only count the mapped list,
|
|
// which is the difference between waiting and reporting a broken feed.
|
|
stub(rawData: const ['not', 'objects']);
|
|
final s = await repo.load();
|
|
expect(s, isA<LoadFailure<List<Map<String, dynamic>>>>());
|
|
},
|
|
);
|
|
});
|
|
|
|
test('a refresh keeps the current data on screen', () async {
|
|
stub(rows: [booking('A')]);
|
|
await repo.load();
|
|
|
|
stub(rows: [booking('A')], delay: const Duration(milliseconds: 30));
|
|
final seen = <LoadState<List<Map<String, dynamic>>>>[];
|
|
final sub = repo.changes.listen(seen.add);
|
|
|
|
await repo.load(force: true);
|
|
await sub.cancel();
|
|
|
|
final duringRefresh = seen.first;
|
|
expect(duringRefresh, isA<LoadData<List<Map<String, dynamic>>>>());
|
|
expect(
|
|
(duringRefresh as LoadData).refreshing,
|
|
isTrue,
|
|
reason: 'a pull to refresh must not blank the list it is refreshing',
|
|
);
|
|
});
|
|
|
|
test('every listener sees the same answer', () async {
|
|
stub(rows: [booking('A')]);
|
|
final a = <LoadState<List<Map<String, dynamic>>>>[];
|
|
final b = <LoadState<List<Map<String, dynamic>>>>[];
|
|
final subA = repo.changes.listen(a.add);
|
|
final subB = repo.changes.listen(b.add);
|
|
|
|
await repo.load();
|
|
// Let the broadcast drain before cancelling — a stream event is delivered
|
|
// on a microtask, so cancelling in the same turn drops the last one.
|
|
await Future<void>.delayed(Duration.zero);
|
|
await subA.cancel();
|
|
await subB.cancel();
|
|
|
|
// Both listeners saw the same sequence of the same objects: there is one
|
|
// copy of the day in memory, not one per screen.
|
|
expect(a, hasLength(b.length));
|
|
for (var i = 0; i < a.length; i++) {
|
|
expect(identical(a[i], b[i]), isTrue, reason: 'event $i differed');
|
|
}
|
|
expect(a.last.valueOrNull, isNotNull);
|
|
expect(identical(a.last, repo.state), isTrue);
|
|
});
|
|
}
|
|
|
|
class _Dead implements Exception {
|
|
const _Dead();
|
|
}
|