- DESIGN.md: full design incl. driving-trip requirements (R1-R4), stays model, focus mode, mobile, provenance rules - SURVEY.md: open-source landscape - mock/: interaction mock (Florence itinerary, focus mode, stays, region stops, mobile layout) - router/: Go module (stdlib-only) with Router interface (Valhalla + OSRM backends), stop_cost, optimize_stops, corridor, routectl CLI, bench (5 real NE-corridor tasks, 26 checks passing), integration tests, and setup-osrm.sh for the self-hosted router - osm/: NH+MA+CT+NY PBFs (gitignored) + setup artifacts
146 lines
4.0 KiB
Go
146 lines
4.0 KiB
Go
package plan
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"maps/router/internal/route"
|
|
)
|
|
|
|
// synthMatrix builds a (n+2)-layout matrix from a full (n+2)² symmetric
|
|
// matrix in seconds.
|
|
func synthMatrix(vals [][]float64) route.Matrix {
|
|
m := make(route.Matrix, len(vals))
|
|
for i := range m {
|
|
m[i] = append([]float64(nil), vals[i]...)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// 3 stops, A=3, C=4.
|
|
// Travel times (seconds):
|
|
//
|
|
// A→C direct: 3600 (60 min)
|
|
// A→S0 600, S0→C 3600 → detour 600 (10 min)
|
|
// A→S1 1200, S1→C 3600 → detour 1200 (20 min)
|
|
// A→S2 1800, S2→C 3600 → detour 1800 (30 min)
|
|
// S0→S1 600, S1→S2 600, S0→S2 1200
|
|
func testMatrix() route.Matrix {
|
|
return synthMatrix([][]float64{
|
|
{0, 600, 1200, 600, 3600}, // S0
|
|
{600, 0, 600, 1200, 3600}, // S1
|
|
{1200, 600, 0, 1800, 3600}, // S2
|
|
{600, 1200, 1800, 0, 3600}, // A
|
|
{3600, 3600, 3600, 3600, 0}, // C
|
|
})
|
|
}
|
|
|
|
var testStops = []Stop{
|
|
{ID: "s0", Name: "S0", DwellMin: 30},
|
|
{ID: "s1", Name: "S1", DwellMin: 30},
|
|
{ID: "s2", Name: "S2", DwellMin: 30},
|
|
}
|
|
|
|
func TestStopCost(t *testing.T) {
|
|
m := testMatrix()
|
|
c := StopCost(m, 0, testStops[0])
|
|
if c.DetourMin != 10 {
|
|
t.Errorf("S0 detour = %d, want 10", c.DetourMin)
|
|
}
|
|
if c.DirectMin != 60 {
|
|
t.Errorf("direct = %d, want 60", c.DirectMin)
|
|
}
|
|
if c.TotalMin != 10+30+10 {
|
|
t.Errorf("total = %d, want 50", c.TotalMin)
|
|
}
|
|
c2 := StopCost(m, 2, testStops[2])
|
|
if c2.DetourMin != 30 {
|
|
t.Errorf("S2 detour = %d, want 30", c2.DetourMin)
|
|
}
|
|
}
|
|
|
|
func TestSeqCostOrderMatters(t *testing.T) {
|
|
m := testMatrix()
|
|
// A→S0→S1→C: 600+600+3600 = 4800 vs 3600 → detour 1200 s = 20 min
|
|
d, total := SeqCost(m, []int{0, 1}, testStops)
|
|
if d != 20 {
|
|
t.Errorf("detour = %d, want 20", d)
|
|
}
|
|
if total != 20+30+10+30+10 {
|
|
t.Errorf("total = %d, want 100", total)
|
|
}
|
|
}
|
|
|
|
func TestOptimizeExhaustivePicksmm(t *testing.T) {
|
|
m := testMatrix()
|
|
// k=1: best single stop is S0 (detour 10 + dwell 30 + ovh 10 = 50)
|
|
r := OptimizeStops(m, testStops, 1, 0)
|
|
if len(r.Order) != 1 || r.Order[0] != 0 {
|
|
t.Fatalf("k=1 order = %v, want [0]", r.Order)
|
|
}
|
|
if r.TotalMin != 50 {
|
|
t.Errorf("k=1 total = %d, want 50", r.TotalMin)
|
|
}
|
|
// k=2: best pair: [0,1]: detour 20, total 20+80 = 100
|
|
// [0,2]: A→S0(600)+S0→S2(1200)+S2→C(3600)=5400 → detour 30 min, total 30+80=110
|
|
// [1,2]: 1200+600+3600=5400 → detour 30, total 110
|
|
r2 := OptimizeStops(m, testStops, 2, 0)
|
|
if len(r2.Order) != 2 || r2.Order[0] != 0 || r2.Order[1] != 1 {
|
|
t.Fatalf("k=2 order = %v, want [0 1]", r2.Order)
|
|
}
|
|
if r2.TotalMin != 100 {
|
|
t.Errorf("k=2 total = %d, want 100", r2.TotalMin)
|
|
}
|
|
}
|
|
|
|
func TestOptimizeBudgetFeasibilityFirst(t *testing.T) {
|
|
m := testMatrix()
|
|
// budget 55 min: k=1 → S0 (50) feasible; k=2 → [0,1] (100) infeasible,
|
|
// so best feasible is k=1 S0 (50).
|
|
r := OptimizeStops(m, testStops, 2, 55)
|
|
if len(r.Order) != 1 || r.Order[0] != 0 {
|
|
t.Fatalf("budget 55: order = %v, want [0]", r.Order)
|
|
}
|
|
if r.TotalMin != 50 {
|
|
t.Errorf("total = %d, want 50", r.TotalMin)
|
|
}
|
|
}
|
|
|
|
// TestOptimizeGreedyMatchesExhaustive checks the greedy fallback
|
|
// (n > 10) against the exhaustive optimum on a small-ish case forced
|
|
// through both code paths.
|
|
func TestOptimizeGreedyMatchesExhaustive(t *testing.T) {
|
|
// 12 stops on a line: A→C 60 min, stops evenly placed, dwell 20 each.
|
|
n := 12
|
|
vals := make([][]float64, n+2)
|
|
// place on a line: position 0..13; A at 0, C at 13, stops at 1..12.
|
|
pos := make([]float64, n+2)
|
|
for i := 0; i < n; i++ {
|
|
pos[i] = float64(i + 1)
|
|
}
|
|
pos[n] = 0
|
|
pos[n+1] = 13
|
|
for i := 0; i < n+2; i++ {
|
|
vals[i] = make([]float64, n+2)
|
|
for j := 0; j < n+2; j++ {
|
|
d := pos[i] - pos[j]
|
|
if d < 0 {
|
|
d = -d
|
|
}
|
|
vals[i][j] = d * 600 // 10 min per unit
|
|
}
|
|
}
|
|
m := synthMatrix(vals)
|
|
stops := make([]Stop, n)
|
|
for i := range stops {
|
|
stops[i] = Stop{ID: string(rune('a' + i)), DwellMin: 20}
|
|
}
|
|
k := 3
|
|
exp := optimizeExhaustive(m, stops, k, 0)
|
|
// force greedy by calling with n > exhaustiveMax
|
|
got := optimizeGreedy(m, stops, k, 0)
|
|
if exp.TotalMin != got.TotalMin {
|
|
t.Errorf("greedy total %d != exhaustive total %d (order %v vs %v)",
|
|
got.TotalMin, exp.TotalMin, got.Order, exp.Order)
|
|
}
|
|
}
|