310 lines
13 KiB
Dart
310 lines
13 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:miler/Models/stop_status.dart';
|
|
import 'package:miler/data/api_config.dart';
|
|
import 'package:miler/data/api_status.dart';
|
|
import 'package:miler/data/miler_api.dart';
|
|
import 'package:miler/data/consignment_state.dart';
|
|
import 'package:miler/data/lifecycle.dart';
|
|
import 'package:miler/data/milk_run.dart';
|
|
import 'package:miler/data/service_profile.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// PICKED IS NOT ACTIVE, AND ONLY THE SLIDER MOVES IT
|
|
///
|
|
/// The production lifecycle the hub, the console and this app must all agree
|
|
/// on:
|
|
///
|
|
/// ```
|
|
/// Pending → Accepted → Arrived → Picked → Active → Delivered
|
|
/// ▲ ▲
|
|
/// pickup-complete start-delivery
|
|
/// Collected_By_Miler Out_for_Delivery
|
|
/// ```
|
|
///
|
|
/// Two separate defects collapsed **Picked** into **Active**, and fixing either
|
|
/// alone leaves the symptom:
|
|
///
|
|
/// 1. **Backend.** With `MILER_COLLECTED_STATE_ENABLED` off, `pickup-complete`
|
|
/// writes `Out_for_Delivery` itself. Not reachable from this app and not
|
|
/// testable here — it is the deployment's behaviour. What IS pinned below
|
|
/// is that the app never claims Picked over such a response.
|
|
///
|
|
/// 2. **App.** `MyPickups.startPickup` released the consignment when the rider
|
|
/// merely *tapped a card* — one tap after Picked, before any slider. That
|
|
/// is invisible while the flag is off (the pivot has already released, so
|
|
/// the release costs no request) and becomes the whole bug the moment the
|
|
/// flag is flipped. Removed; pinned here.
|
|
///
|
|
/// These tests are the reason the flag flip is safe. Read them before changing
|
|
/// any call site that reaches `MilerApi.startDelivery`.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 1 · PICKED DOES NOT CALL START-DELIVERY
|
|
//
|
|
// Enforced at the source, because this is a statement about *call sites*
|
|
// and no behavioural test can prove the absence of one. Every route to
|
|
// `MilerApi.startDelivery` is enumerated; a new one fails this test and has
|
|
// to be justified here rather than discovered in production.
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('only the rider setting off releases a consignment', () {
|
|
/// Every `lib/` file that mentions a symbol, with its line numbers.
|
|
Map<String, List<int>> sitesOf(String symbol) {
|
|
final out = <String, List<int>>{};
|
|
for (final e in Directory('lib').listSync(recursive: true)) {
|
|
if (e is! File || !e.path.endsWith('.dart')) continue;
|
|
final lines = e.readAsLinesSync();
|
|
for (var i = 0; i < lines.length; i++) {
|
|
final l = lines[i];
|
|
// Prose in a doc comment is not a call.
|
|
if (l.trimLeft().startsWith('//') || l.trimLeft().startsWith('///')) {
|
|
continue;
|
|
}
|
|
if (l.contains(symbol)) (out[e.path] ??= []).add(i + 1);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
test('`MilerApi.startDelivery` has exactly one caller', () {
|
|
final sites = sitesOf('MilerApi.startDelivery(');
|
|
expect(
|
|
sites.keys.toSet(),
|
|
{'lib/views/Dashboard/pickups/delivery_actions.dart'},
|
|
reason:
|
|
'the release is `releaseForDelivery`\'s alone. A second caller is '
|
|
'a second lifecycle, and the console sees both.',
|
|
);
|
|
});
|
|
|
|
test('`releaseForDelivery` is reached only from setting off or the door', () {
|
|
final sites = sitesOf('releaseForDelivery(')
|
|
..remove('lib/views/Dashboard/pickups/delivery_actions.dart');
|
|
expect(
|
|
sites.keys.toSet(),
|
|
{
|
|
// The Start-ride slider on the stop's own map.
|
|
'lib/views/Dashboard/pickups/map.dart',
|
|
// The Start-round bar, and nothing else on that screen. This file
|
|
// held the defect: `startPickup` called `startRound` on card open.
|
|
'lib/views/Dashboard/pickups/pickups.dart',
|
|
},
|
|
reason: 'a new caller must be a deliberate "the rider has set off"',
|
|
);
|
|
});
|
|
|
|
test('tapping a card does not release — the defect, pinned', () {
|
|
// `startPickup` opens a stop's map. It must contain no release: the
|
|
// rider tapping a card to read an address has not set off, and
|
|
// `navigatesToCustomer` is true from the instant Picked succeeds.
|
|
final src = File(
|
|
'lib/views/Dashboard/pickups/pickups.dart',
|
|
).readAsStringSync();
|
|
final start = src.indexOf('Future<void> startPickup(');
|
|
expect(start, greaterThan(0), reason: 'startPickup was renamed');
|
|
final end = src.indexOf('Future<void> resumePickup(', start);
|
|
expect(end, greaterThan(start));
|
|
|
|
final body = src
|
|
.substring(start, end)
|
|
.split('\n')
|
|
.where((l) => !l.trimLeft().startsWith('//'))
|
|
.join('\n');
|
|
|
|
expect(
|
|
body.contains('startRound('),
|
|
isFalse,
|
|
reason:
|
|
'opening a stop released it, so the console read Active one tap '
|
|
'after Picked and before any slider was touched',
|
|
);
|
|
expect(body.contains('releaseForDelivery('), isFalse);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 2 · COLLECTED_BY_MILER IS PICKED, AND STAYS PICKED
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('Collected_By_Miler reads as Picked', () {
|
|
test('the pivot response is read as collected, not as released', () {
|
|
final t = MilerLifecycle.pickupComplete(
|
|
ApiResult(
|
|
ok: true,
|
|
status: 200,
|
|
data: const {
|
|
'bookingid': 78,
|
|
'consignmentid': 34,
|
|
'status': 'Converted_To_Consignment',
|
|
'consignmentstatus': 'Collected_By_Miler',
|
|
'next_action': 'start_delivery',
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(t.isConfirmed, isTrue);
|
|
expect(t.bookingStatus, BookingStatus.convertedToConsignment);
|
|
expect(t.consignmentState, ConsignmentState.collectedByMiler);
|
|
expect(t.consignmentId, '34');
|
|
expect(t.awaitsStartDelivery, isTrue);
|
|
expect(
|
|
t.isCompatibilityMode,
|
|
isFalse,
|
|
reason: 'the flag is ON in this response — the release is still to come',
|
|
);
|
|
});
|
|
|
|
test('a collected stop shows the Picked rung', () {
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
final stop = <String, dynamic>{
|
|
'orderid': 'A1',
|
|
'orderstatus': ApiConfig.legacyStatusFromConsignment(
|
|
'Collected_By_Miler',
|
|
),
|
|
'consignmentstatus': 'Collected_By_Miler',
|
|
};
|
|
expect(stop['orderstatus'], 'picked', reason: 'adapter contract');
|
|
expect(
|
|
MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}),
|
|
StopStatus.picked,
|
|
);
|
|
});
|
|
|
|
test('and it still needs releasing — the bar has work to offer', () {
|
|
expect(
|
|
consignmentStateFromRaw('Collected_By_Miler').needsRelease,
|
|
isTrue,
|
|
);
|
|
expect(
|
|
consignmentStateFromRaw('Collected_By_Miler').isDeliverable,
|
|
isFalse,
|
|
reason: '`deliver` accepts Out_for_Delivery only',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 3 · START RIDE IS WHAT MAKES IT ACTIVE
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('Start ride moves collected → out for delivery', () {
|
|
test('the released state is the Active rung, and only via the local set', () {
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
final stop = <String, dynamic>{
|
|
'orderid': 'A1',
|
|
'orderstatus': ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'),
|
|
'consignmentstatus': 'Out_for_Delivery',
|
|
};
|
|
|
|
// Before the slider: the rider's own record does not carry it, so the
|
|
// rung is Picked even though the consignment is released.
|
|
expect(
|
|
MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}),
|
|
StopStatus.picked,
|
|
);
|
|
|
|
// After the slider: `startRound` writes the id, and only then.
|
|
expect(
|
|
MilkRun.stageOf(
|
|
stop,
|
|
acceptedIds: const {},
|
|
collectedIds: const {'A1'},
|
|
outForDeliveryIds: const {'A1'},
|
|
),
|
|
StopStatus.outForDelivery,
|
|
);
|
|
});
|
|
|
|
test('a released consignment is deliverable and needs no second release', () {
|
|
final s = consignmentStateFromRaw('Out_for_Delivery');
|
|
expect(s.isDeliverable, isTrue);
|
|
expect(s.needsRelease, isFalse);
|
|
expect(ConsignmentGate.gateFor(s), DeliverGate.deliverable);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 4 · A STALE OR EARLY ROW CANNOT COLLAPSE PICKED INTO ACTIVE
|
|
//
|
|
// `GET /miler/bookings` is a poll behind the rider by design. With the flag
|
|
// OFF the pivot releases the consignment itself, so the very next poll
|
|
// reports `Out_for_Delivery` for a bag still on the counter. The rider's
|
|
// card must not jump to Active on that — his own set is what says he set
|
|
// off. This is the app half of the compatibility-mode disagreement, and it
|
|
// is display only: nothing here is sent to the server.
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
group('polling cannot start the round for the rider', () {
|
|
test('compatibility mode is reported, never silently adopted', () {
|
|
final t = MilerLifecycle.pickupComplete(
|
|
ApiResult(
|
|
ok: true,
|
|
status: 200,
|
|
data: const {
|
|
'bookingid': 78,
|
|
'consignmentid': 34,
|
|
'consignmentstatus': 'Out_for_Delivery',
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(t.isConfirmed, isTrue);
|
|
expect(t.consignmentState, ConsignmentState.outForDelivery);
|
|
expect(
|
|
t.isCompatibilityMode,
|
|
isTrue,
|
|
reason:
|
|
'the flag is OFF on this deployment — the pivot released it, and '
|
|
'the console showing Active is the SERVER\'s state, not a client '
|
|
'bug. Reported so the trace names the backend.',
|
|
);
|
|
expect(t.awaitsStartDelivery, isFalse);
|
|
});
|
|
|
|
test('an early Out_for_Delivery row still draws Picked', () {
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
// Exactly what the next poll returns in compatibility mode.
|
|
final stop = <String, dynamic>{
|
|
'orderid': 'A1',
|
|
'orderstatus': 'outfordelivery',
|
|
'consignmentstatus': 'Out_for_Delivery',
|
|
};
|
|
expect(
|
|
MilkRun.stageOf(stop, acceptedIds: const {}, collectedIds: const {'A1'}),
|
|
StopStatus.picked,
|
|
reason:
|
|
'the rider has not slid Start ride; the row is the consignment\'s '
|
|
'state, not his',
|
|
);
|
|
});
|
|
|
|
test('but a genuinely finished stop is never dragged backwards', () {
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
for (final raw in const ['Delivered', 'delivered']) {
|
|
// Built the way `ApiConfig.pickupFromBooking` builds a real row: the
|
|
// consignment's word is translated into `orderstatus`, which is what
|
|
// `stopStatusOf` reads. Setting `consignmentstatus` alone would be a
|
|
// row this app never sees, and would prove nothing about the ladder.
|
|
final stop = <String, dynamic>{
|
|
'orderid': 'A1',
|
|
'orderstatus': ApiConfig.legacyStatusFromConsignment(raw),
|
|
'consignmentstatus': raw,
|
|
};
|
|
expect(stop['orderstatus'], 'delivered', reason: 'adapter contract');
|
|
expect(
|
|
MilkRun.stageOf(
|
|
stop,
|
|
acceptedIds: const {},
|
|
collectedIds: const {'A1'},
|
|
),
|
|
StopStatus.delivered,
|
|
reason: 'precedence must only ever hold a stop back, never a close',
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|