Files
doormile_milderapp/test/work_repository_test.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

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();
}