Files
doormile_backend/internal/routing/optimizer_test.go
Suriya 0288fb7af8 feat: order a rider's stops using the Route Optimization API
Assignment decided who carried a booking but never what order to run
several of them in -- the one capability jupiter had that Doormile did
not. It turns out we already own the solver: routes.workolik.com is a
live in-house Route Optimization API backed by Valhalla road-network
routing, not the paid third party I had assumed. This is a client for
it, not a solver.

internal/routing posts a rider's active stops and writes back step,
previouskms, cumulativekms and ETA onto BookingAssignment. HubBatchAssign
calls it after committing a batch, which is exactly the case it exists
for: a rider used to walk away with several bookings and no order to run
them in. GetMilerAssignments now returns sequenced stops in step order,
falling back to newest-first for anything unsequenced.

Contract discovered by probing the live service -- the OpenAPI schema
types the body as a bare object array, so the field names are not
documented anywhere. They are pickuplat/pickuplong/deliverylat/
deliverylong, NOT pickuplatitude/deliverylatitude. Sending the wrong
names does not fail: it returns HTTP 200 with every coordinate defaulted
to "0.0", no reordering and all distances zero. That trap is recorded in
a comment so the next person does not lose an afternoon to it.

Numeric fields come back inconsistently typed -- previouskms as a number,
actualkms and eta as strings, some decimal -- so they are decoded loosely
and coerced, with tests pinning the coercion. Steps for deliveryids we
did not send are discarded rather than written, so an echoed or stale id
cannot reorder another rider's work.

Sequencing is best-effort throughout and runs after assignments commit.
The optimizer is a separate service over the network; it being down must
leave bookings assigned but unordered, never undo the batch. Step 0 means
"not sequenced", not "first".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:06:24 +05:30

69 lines
1.9 KiB
Go

package routing
import "testing"
// The optimizer returns the same logical field as a number in one place and a
// string in another — previouskms came back as 4, actualkms as "5.09", eta as
// "20". Binding those to concrete types would have silently zeroed half the
// response, so the coercion is worth pinning.
func TestAsFloat(t *testing.T) {
cases := []struct {
name string
in interface{}
want float64
}{
{"number", float64(4), 4},
{"decimal string", "5.09", 5.09},
{"integer string", "14", 14},
{"zero string the API sends for missing coords", "0.0", 0},
{"nil", nil, 0},
{"unparseable", "n/a", 0},
{"wrong type", true, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := asFloat(tc.in); got != tc.want {
t.Fatalf("asFloat(%#v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
func TestAsInt(t *testing.T) {
cases := []struct {
name string
in interface{}
want int
}{
{"number", float64(3), 3},
{"integer string", "20", 20},
// Atoi would fail on this and yield 0, which as a step number would
// silently drop the stop from the sequence.
{"decimal string", "20.0", 20},
{"truncates rather than rounds", "20.9", 20},
{"nil", nil, 0},
{"unparseable", "", 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := asInt(tc.in); got != tc.want {
t.Fatalf("asInt(%#v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
// Coordinates must go out as strings with real precision. Formatting with too
// few decimals would collapse neighbouring delivery addresses onto the same
// point and make the ordering meaningless.
func TestCoordKeepsPrecision(t *testing.T) {
if got := coord(11.0045); got != "11.004500" {
t.Fatalf("coord(11.0045) = %q", got)
}
// ~11m apart in Coimbatore; these must not format identically.
a, b := coord(11.004500), coord(11.004600)
if a == b {
t.Fatalf("distinct coordinates formatted identically: %q", a)
}
}