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) } }