New agent that owns the DoormileExpress dispatch flow. The normal B2C booking flow is untouched; this handles express orders that a console operator has let accumulate batch by batch, then dispatches in one go. Triggered by express.dispatch_requested (the console's manual dispatch action). For the tenant it: reads the pending bookings and the tenant's available riders (Go internal API), distributes them across riders by proximity + load with a radius guard, asks routes.workolik.com (Valhalla) for each rider's stop order, and writes the assignments back through the Go internal API — which stays the single writer of assignment state; the agent only decides who and what order. - agents/express_dispatch_agent.py: the agent (mirrors DispatchAgent's NATS binding; pure consumer of the backend-owned EXPRESS stream). - config/system_config.py: ROUTE_OPTIMIZER_URL. - registered under JARVIS in main.py / agents package. - tests: distribution (radius, load, cap, no-GPS fallback) and sequencing (single-stop, remap, optimizer-down, unknown-id) — deterministic, no network. Gated off by default on the backend (EXPRESS_AGENT_ENABLED); EXPRESS_AGENT_ AUTONOMOUS=false runs it observe-only (logs the plan, writes nothing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
5.9 KiB
Python
142 lines
5.9 KiB
Python
"""
|
|
Unit tests for the ExpressDispatchAgent — the DoormileExpress batch flow.
|
|
|
|
Focus, in the spirit of the other agent tests: the distribution must respect the
|
|
radius guard, the per-rider cap, and load balancing; and sequencing must survive
|
|
an optimizer that is down, wrong, or echoing ids we never sent, without ever
|
|
raising or writing a stop for a booking we did not assign.
|
|
|
|
The agent is built with __new__ so NATS / the message bus are never touched, and
|
|
the optimizer HTTP call is monkeypatched — no network.
|
|
|
|
Run:
|
|
python -m unittest discover -s tests
|
|
"""
|
|
import asyncio
|
|
import unittest
|
|
|
|
import agents.express_dispatch_agent as eda
|
|
from agents.express_dispatch_agent import ExpressDispatchAgent
|
|
|
|
|
|
def _bare_agent():
|
|
# Skip __init__ (Redis/NATS/registration); we only exercise pure decision code.
|
|
return ExpressDispatchAgent.__new__(ExpressDispatchAgent)
|
|
|
|
|
|
def _booking(bid, plat=11.0045, plon=76.9612, dlat=11.05, dlon=76.93):
|
|
return {
|
|
"booking_id": bid, "booking_no": f"DM-{bid}",
|
|
"pickuplatitude": plat, "pickuplongitude": plon,
|
|
"deliverylatitude": dlat, "deliverylongitude": dlon,
|
|
}
|
|
|
|
|
|
def _rider(mid, lat, lon):
|
|
return {"miler_user_id": mid, "latitude": lat, "longitude": lon}
|
|
|
|
|
|
class DistributionTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.agent = _bare_agent()
|
|
|
|
def test_splits_load_across_near_riders(self):
|
|
bookings = [_booking(58), _booking(59), _booking(60)]
|
|
riders = [_rider(38, 11.01, 76.96), _rider(41, 11.00, 76.97)]
|
|
out = self.agent._distribute(bookings, riders)
|
|
# Both riders used; nobody gets all three (load penalty spreads them).
|
|
self.assertEqual(set(out.keys()), {38, 41})
|
|
self.assertTrue(all(1 <= len(v) <= 2 for v in out.values()))
|
|
self.assertEqual(sum(len(v) for v in out.values()), 3)
|
|
|
|
def test_radius_guard_excludes_far_rider(self):
|
|
bookings = [_booking(58), _booking(59)]
|
|
# 38 is in Coimbatore; 99 is in Nagercoil (~200 km away) and must be excluded.
|
|
riders = [_rider(38, 11.01, 76.96), _rider(99, 8.18, 77.43)]
|
|
out = self.agent._distribute(bookings, riders)
|
|
self.assertEqual(set(out.keys()), {38})
|
|
self.assertEqual(99 in out, False)
|
|
|
|
def test_no_gps_rider_assumed_at_pickup(self):
|
|
bookings = [_booking(58)]
|
|
riders = [_rider(38, 0, 0)] # freshly Available, no fix yet
|
|
out = self.agent._distribute(bookings, riders)
|
|
self.assertIn(38, out) # not excluded by the radius guard
|
|
self.assertEqual(len(out[38]), 1)
|
|
self.assertEqual(out[38][0]["booking_id"], 58)
|
|
|
|
def test_per_rider_cap_leaves_overflow_unassigned(self):
|
|
# 7 bookings, one rider, cap 5 -> 5 assigned, 2 left for manual assignment.
|
|
old = eda.MAX_PER_RIDER
|
|
eda.MAX_PER_RIDER = 5
|
|
try:
|
|
bookings = [_booking(i) for i in range(7)]
|
|
riders = [_rider(38, 11.01, 76.96)]
|
|
out = self.agent._distribute(bookings, riders)
|
|
self.assertEqual(len(out[38]), 5)
|
|
finally:
|
|
eda.MAX_PER_RIDER = old
|
|
|
|
|
|
class SequenceTests(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self):
|
|
self.agent = _bare_agent()
|
|
|
|
async def test_single_stop_is_step_one_without_calling_optimizer(self):
|
|
called = []
|
|
|
|
async def fake_post(*a, **k):
|
|
called.append(1)
|
|
return None
|
|
|
|
eda.api_post = fake_post
|
|
rows = await self.agent._sequence(13, _rider(38, 11.01, 76.96), [_booking(58)])
|
|
self.assertEqual(called, []) # no HTTP for a single stop
|
|
self.assertEqual(len(rows), 1)
|
|
self.assertEqual(rows[0]["step"], 1)
|
|
self.assertEqual(rows[0]["booking_id"], 58)
|
|
|
|
async def test_maps_optimizer_steps_back_by_bookingid(self):
|
|
async def fake_post(url, **k):
|
|
return {"success": True, "stops": [
|
|
{"bookingid": 60, "step": 1, "previouskms": 0, "cumulativekms": 0, "etaminutes": 0, "cumulativeeta": 0},
|
|
{"bookingid": 58, "step": 2, "previouskms": 4, "cumulativekms": 4, "etaminutes": 12, "cumulativeeta": 12},
|
|
{"bookingid": 59, "step": 3, "previouskms": 5, "cumulativekms": 9, "etaminutes": 9, "cumulativeeta": 21},
|
|
]}
|
|
eda.api_post = fake_post
|
|
stops = [_booking(58), _booking(59), _booking(60)]
|
|
rows = await self.agent._sequence(13, _rider(38, 11.01, 76.96), stops)
|
|
by_id = {r["booking_id"]: r for r in rows}
|
|
self.assertEqual(by_id[60]["step"], 1)
|
|
self.assertEqual(by_id[58]["step"], 2)
|
|
self.assertEqual(by_id[59]["cumulativeeta"], 21)
|
|
|
|
async def test_optimizer_down_assigns_unsequenced_step_zero(self):
|
|
async def fake_post(url, **k):
|
|
return None # 5xx / network -> http client returns None
|
|
eda.api_post = fake_post
|
|
stops = [_booking(58), _booking(59)]
|
|
rows = await self.agent._sequence(13, _rider(38, 11.01, 76.96), stops)
|
|
self.assertEqual(len(rows), 2)
|
|
self.assertTrue(all(r["step"] == 0 for r in rows)) # assigned, just unordered
|
|
|
|
async def test_unknown_bookingid_never_leaks_but_stop_still_assigned(self):
|
|
async def fake_post(url, **k):
|
|
return {"success": True, "stops": [
|
|
{"bookingid": 999, "step": 1}, # never sent — must be dropped
|
|
{"bookingid": 58, "step": 2},
|
|
]}
|
|
eda.api_post = fake_post
|
|
stops = [_booking(58), _booking(59)]
|
|
rows = await self.agent._sequence(13, _rider(38, 11.01, 76.96), stops)
|
|
ids = {r["booking_id"] for r in rows}
|
|
self.assertNotIn(999, ids) # echoed unknown id discarded
|
|
self.assertEqual(ids, {58, 59}) # both real stops still assigned
|
|
by_id = {r["booking_id"]: r for r in rows}
|
|
self.assertEqual(by_id[58]["step"], 2)
|
|
self.assertEqual(by_id[59]["step"], 0) # dropped by optimizer -> unsequenced
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|