import 'package:flutter_test/flutter_test.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/data/task_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── /// ONE MILER, MIXED WORK — asked of the job, not of the rider /// /// The scenario this exists for, stated as the operation states it: /// /// A rider in RS Puram is assigned **10 kitchen parcels and 5 CX customer /// pickups**. All fifteen are his, in one shift, on one route. /// /// Before this, every operational question was answered by /// `ServiceProfile.active` — a static resolved once from the rider's tenant, /// giving the *same answer for all fifteen rows*. So on a meal tenant the five /// CX pickups were walked straight past the door flow (no parcel count, no /// destination, no weight, no payment) and pivoted on whatever the customer had /// typed into the app days earlier. On a parcel tenant the reverse: ten kitchen /// bags with a known manifest asked the rider to itemise each one. /// /// [WorkPolicy] is the bridge. It asks the **row**, and falls back to the line /// only where the row is silent — because most rows still are (see BE-6), and a /// screen that took `unknown` at face value would lose the milk round its /// kitchen headings, which is what happened the two previous times a call site /// was migrated wholesale. /// /// These tests pin both halves: the row wins when it speaks, and nothing /// changes when it does not. /// ───────────────────────────────────────────────────────────────────────── void main() { setUp(() => ServiceProfile.setActive(ServiceProfile.parcel)); tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); /// A CX customer pickup, as the queue returns it. Map cxPickup() => ApiConfig.pickupFromBooking(const { 'bookingid': 101, 'orderid': 'CX-1', 'pickup_source_type': 'customer', 'pickup_source_name': 'Anitha R', }); /// A kitchen / counter collection. Map sourcePickup() => ApiConfig.pickupFromBooking(const { 'bookingid': 201, 'orderid': 'KIT-1', 'pickup_source_type': 'merchant', 'pickup_source_name': 'Sri Balaji Kitchen', }); /// A row from before these fields existed — the common case today. Map silentRow() => ApiConfig.pickupFromBooking(const { 'bookingid': 301, 'orderid': 'OLD-1', }); group('the row decides, when the row has said something', () { test('a CX customer pickup captures shipment details', () { // The five pickups in the scenario. True on BOTH tenants, which is the // whole change: it is a fact about the job, not about the rider. for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) { ServiceProfile.setActive(line); expect( WorkPolicy.capturesShipmentDetails(cxPickup()), isTrue, reason: 'a customer door needs the verify flow on ${line.label}', ); } }); test('a kitchen collection does not', () { // The ten parcels. Also true on both tenants — the manifest is known // before the rider arrives, whoever he works for. for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) { ServiceProfile.setActive(line); expect( WorkPolicy.capturesShipmentDetails(sourcePickup()), isFalse, reason: 'a counter manifest is not itemised at the door', ); } }); test('the two coexist in one queue, answering differently', () { // The regression in one line: fifteen rows, two workflows, one rider. final queue = [cxPickup(), sourcePickup()]; expect( queue.map(WorkPolicy.capturesShipmentDetails).toList(), [true, false], reason: 'this list was [true, true] or [false, false] before — whichever ' 'the tenant happened to say', ); }); test('a CX pickup never becomes a source/kitchen task', () { final profile = TaskProfile.of(cxPickup()); expect(profile.kind, WorkKind.customerPickup); expect( profile.groupsBySource, isFalse, reason: 'a customer door is not a counter and must not be grouped ' 'under one like a kitchen manifest', ); }); }); group('the line still decides where the row is silent', () { // The guard that makes this safe to ship: every existing row and every // existing fixture takes this path, and must behave exactly as before. test('a silent row follows the parcel line', () { ServiceProfile.setActive(ServiceProfile.parcel); expect( WorkPolicy.capturesShipmentDetails(silentRow()), ServiceProfile.parcel.needsVerification, ); expect( WorkPolicy.capturesShipmentAddresses(silentRow()), ServiceProfile.parcel.capturesShipmentAddresses, ); expect( WorkPolicy.initiatesShipment(silentRow()), ServiceProfile.parcel.initiatesShipment, ); }); test('a silent row follows the meal line', () { ServiceProfile.setActive(ServiceProfile.milkMan); expect( WorkPolicy.capturesShipmentDetails(silentRow()), ServiceProfile.milkMan.needsVerification, ); expect( WorkPolicy.deliversToCustomer(silentRow()), ServiceProfile.milkMan.deliversToCustomer, ); }); }); group('when an accepted stop leaves Home', () { test('a CX pickup leaves on accept; a kitchen load does not', () { // The per-item replacement for `handsOffAtCollection`. Accepting a // customer visit is an appointment the rider now owes somebody, so it // belongs on Bookings. Accepting a counter load puts nothing in his // hands — he still has to go and collect it. expect(WorkPolicy.staysOnHomeUntilCollected(cxPickup()), isFalse); expect(WorkPolicy.staysOnHomeUntilCollected(sourcePickup()), isTrue); }); test('and the answer does not change with the tenant', () { // The whole failure this replaces: one answer for every row on the // screen. On a meal tenant an accepted CX pickup sat on Home beside ten // kitchen bags with nothing to tell them apart. for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) { ServiceProfile.setActive(line); expect( WorkPolicy.staysOnHomeUntilCollected(cxPickup()), isFalse, reason: line.label, ); expect( WorkPolicy.staysOnHomeUntilCollected(sourcePickup()), isTrue, reason: line.label, ); } }); test('a silent row still follows the line', () { for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) { ServiceProfile.setActive(line); expect( WorkPolicy.staysOnHomeUntilCollected(silentRow()), line.handsOffAtCollection, reason: 'existing payloads must behave exactly as before', ); } }); }); group('which surface an item belongs on', () { test('an accepted CX pickup moves Home → Bookings', () { // The rule the scenario names: accepting a customer visit makes it an // appointment the rider owes somebody. final row = cxPickup(); expect(WorkPolicy.surfaceOf(row, accepted: false), WorkSurface.home); expect(WorkPolicy.surfaceOf(row, accepted: true), WorkSurface.bookings); }); test('an accepted kitchen load stays on Home', () { // Accepting a counter load puts nothing in his hands — he still has to go // and collect it. final row = sourcePickup(); expect(WorkPolicy.surfaceOf(row, accepted: false), WorkSurface.home); expect( WorkPolicy.surfaceOf(row, accepted: true), WorkSurface.home, reason: 'nothing is in his hands yet', ); }); test('in-custody work is Deliveries, whichever kind it is', () { for (final action in const ['deliver', 'start_delivery', 'inward_at_hub']) { final row = ApiConfig.pickupFromBooking({ 'bookingid': 401, 'next_action': action, }); expect( WorkPolicy.surfaceOf(row, accepted: true), WorkSurface.deliveries, reason: action, ); } }); test('finished work is Activity', () { final row = ApiConfig.pickupFromBooking(const { 'bookingid': 501, 'next_action': 'handed_to_hub', }); expect(WorkPolicy.surfaceOf(row, accepted: true), WorkSurface.activity); }); }); group('a base leg is read, never guessed', () { test('only an explicit inward_at_hub ends at a base', () { expect( WorkPolicy.endsAtHub( ApiConfig.pickupFromBooking(const { 'bookingid': 601, 'next_action': 'inward_at_hub', }), ), isTrue, ); }); test('a silent row does NOT fall back to the line here', () { // Deliberately the one method with no fallback. Guessing a base leg from // the rider's tenant is how a hyperlocal parcel gets routed to a counter // and sits there. ServiceProfile.setActive(ServiceProfile.parcel); expect(ServiceProfile.parcel.endsAtHub, isTrue); expect( WorkPolicy.endsAtHub(silentRow()), isFalse, reason: 'the line says the day ends at a base; this ROW does not', ); }); test('a customer delivery does not end at a base', () { expect( WorkPolicy.endsAtHub( ApiConfig.pickupFromBooking(const { 'bookingid': 701, 'next_action': 'deliver', }), ), isFalse, ); }); }); group('the RS Puram round', () { test('15 assigned items keep 15 distinct answers, and none vanish', () { ServiceProfile.setActive(ServiceProfile.milkMan); final round = >[ for (var i = 0; i < 10; i++) ApiConfig.pickupFromBooking({ 'bookingid': 1000 + i, 'orderid': 'KIT-$i', 'pickup_source_type': 'merchant', 'pickup_source_name': 'Kitchen 2', }), for (var i = 0; i < 5; i++) ApiConfig.pickupFromBooking({ 'bookingid': 2000 + i, 'orderid': 'CX-$i', 'pickup_source_type': 'customer', 'pickup_source_name': 'Customer $i', }), ]; expect(round, hasLength(15), reason: 'nothing may be filtered away'); final kitchens = round.where( (r) => TaskProfile.of(r).kind == WorkKind.sourcePickup, ); final customers = round.where( (r) => TaskProfile.of(r).kind == WorkKind.customerPickup, ); expect(kitchens, hasLength(10)); expect(customers, hasLength(5)); // Accepting all fifteen sends the two kinds to two different surfaces. expect( kitchens.map((r) => WorkPolicy.surfaceOf(r, accepted: true)).toSet(), {WorkSurface.home}, ); expect( customers.map((r) => WorkPolicy.surfaceOf(r, accepted: true)).toSet(), {WorkSurface.bookings}, ); // And only the five customer doors run the verify flow — on a meal // tenant, where previously none of them would have. expect( customers.every(WorkPolicy.capturesShipmentDetails), isTrue, ); expect( kitchens.any(WorkPolicy.capturesShipmentDetails), isFalse, ); }); }); }