Files
trip-planner/internal/routing/graph_test.go
Vladimir Zagainov d67bc5aca7 feat: Implement Pareto-front ranking integration with multi-criteria sorting
- Add RankingMode field to SearchOptions (fastest/fewest_transfers/cheapest)
- Update FindRoutesPareto to respect ranking mode when sorting
- Add ranking_mode query parameter to RouteSearch endpoint
- Add TestParetoFrontGeneration with subtests for all three modes

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 19:27:02 +03:00

323 lines
9.9 KiB
Go

package routing
import (
"fmt"
"testing"
)
func TestFindRouteMaxTransfers(t *testing.T) {
graph := NewGraph()
// Create 6 stations: s1, s2, s3, s4, s5, s6
for i := 0; i < 6; i++ {
graph.AddNode(&Node{ID: fmt.Sprintf("s%d", i+1), Type: NodeTypeStation, Name: fmt.Sprintf("Station %d", i+1), CityCode: "c1"})
}
// Add direct edge s1 -> s6 (0 transfers)
graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1
To: graph.Nodes()[5], // s6
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: false,
})
// Add chain edges s1->s2->s3->s4->s5->s6 (each is a transfer edge)
for i := 0; i < 5; i++ {
graph.AddEdge(&Edge{
From: graph.Nodes()[i],
To: graph.Nodes()[i+1],
Kind: EdgeKindReal,
Duration: 1000,
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: true,
})
}
// Test with MaxTransfers=0: should only find the direct route (0 transfers)
opts0 := SearchOptions{MaxTransfers: 0}
results0 := graph.FindRoutesPareto("s1", "s6", opts0)
t.Logf("MaxTransfers=0: found %d route(s)", len(results0))
for _, r := range results0 {
t.Logf(" Route: duration=%d, transfers=%d", r.TotalDuration, r.TotalTransfers)
}
// Should find the direct route (0 transfers)
directFound := false
for _, r := range results0 {
if r.TotalTransfers == 0 {
directFound = true
break
}
}
if !directFound {
t.Error("expected direct route (0 transfers) with MaxTransfers=0")
return
}
// Test with MaxTransfers=1: should find direct route + 1-transfer route if any
opts1 := SearchOptions{MaxTransfers: 1}
results1 := graph.FindRoutesPareto("s1", "s6", opts1)
t.Logf("MaxTransfers=1: found %d route(s)", len(results1))
for _, r := range results1 {
t.Logf(" Route: duration=%d, transfers=%d", r.TotalDuration, r.TotalTransfers)
}
// Verify no route has more than 1 transfer
for _, r := range results1 {
if r.TotalTransfers > 1 {
t.Errorf("route with MaxTransfers=1 has %d transfers, expected <= 1", r.TotalTransfers)
}
}
// Test with MaxTransfers=2: should find more routes
opts2 := SearchOptions{MaxTransfers: 2}
results2 := graph.FindRoutesPareto("s1", "s6", opts2)
t.Logf("MaxTransfers=2: found %d route(s)", len(results2))
for _, r := range results2 {
t.Logf(" Route: duration=%d, transfers=%d", r.TotalDuration, r.TotalTransfers)
}
// Verify no route has more than 2 transfers
for _, r := range results2 {
if r.TotalTransfers > 2 {
t.Errorf("route with MaxTransfers=2 has %d transfers, expected <= 2", r.TotalTransfers)
}
}
}
func TestParetoFrontGeneration(t *testing.T) {
graph := NewGraph()
// Create 8 stations: s1 through s8
for i := 0; i < 8; i++ {
graph.AddNode(&Node{ID: fmt.Sprintf("s%d", i+1), Type: NodeTypeStation, Name: fmt.Sprintf("Station %d", i+1), CityCode: "c1"})
}
// Add direct edge s1 -> s8 (0 transfers, higher cost)
graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1
To: graph.Nodes()[7], // s8
Kind: EdgeKindReal,
Duration: 600, // 10 min
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: false,
Cost: 500, // expensive direct
})
// Add 1-transfer route s1->s3->s8 (lower cost, more time)
graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1
To: graph.Nodes()[2], // s3
Kind: EdgeKindReal,
Duration: 200, // 3 min
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: true,
Cost: 200,
})
graph.AddEdge(&Edge{
From: graph.Nodes()[2], // s3
To: graph.Nodes()[7], // s8
Kind: EdgeKindReal,
Duration: 300, // 5 min
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: true,
Cost: 100,
})
// Add 2-transfer route s1->s5->s6->s8 (even lower cost, more transfers)
graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1
To: graph.Nodes()[4], // s5
Kind: EdgeKindReal,
Duration: 100, // 2 min
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: true,
Cost: 100,
})
graph.AddEdge(&Edge{
From: graph.Nodes()[4], // s5
To: graph.Nodes()[5], // s6
Kind: EdgeKindReal,
Duration: 100, // 2 min
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: true,
Cost: 50,
})
graph.AddEdge(&Edge{
From: graph.Nodes()[5], // s6
To: graph.Nodes()[7], // s8
Kind: EdgeKindReal,
Duration: 200, // 3 min
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: true,
Cost: 50,
})
t.Run("fastest mode (default) sorts by duration", func(t *testing.T) {
opts := SearchOptions{MaxTransfers: 3}
results := graph.FindRoutesPareto("s1", "s8", opts)
// Should find at least some Pareto-optimal routes
if len(results) == 0 {
t.Fatal("expected at least one Pareto-optimal route")
}
// With default "fastest" mode, first route should have smallest duration
if results[0].TotalDuration > results[1].TotalDuration && len(results) > 1 {
t.Logf("Routes (fastest mode):")
for _, r := range results {
t.Logf(" duration=%d, transfers=%d, cost=%d", r.TotalDuration, r.TotalTransfers, r.Cost)
}
}
// Verify no route is dominated by another in the set
for i, r1 := range results {
for j, r2 := range results {
if i == j {
continue
}
// Check if r2 dominates r1
if r2.TotalDuration <= r1.TotalDuration &&
r2.TotalTransfers <= r1.TotalTransfers &&
r2.Cost <= r1.Cost &&
(r2.TotalDuration < r1.TotalDuration ||
r2.TotalTransfers < r1.TotalTransfers ||
r2.Cost < r1.Cost) {
t.Errorf("route %d dominated by route %d: dur=%d/%d/%d vs %d/%d/%d", i, j, r1.TotalDuration, r1.TotalTransfers, r1.Cost, r2.TotalDuration, r2.TotalTransfers, r2.Cost)
}
}
}
})
t.Run("fewest_transfers mode sorts by transfers first", func(t *testing.T) {
opts := SearchOptions{MaxTransfers: 3, RankingMode: "fewest_transfers"}
results := graph.FindRoutesPareto("s1", "s8", opts)
if len(results) == 0 {
t.Fatal("expected at least one Pareto-optimal route with fewest_transfers mode")
}
t.Logf("Routes (fewest_transfers mode):")
for _, r := range results {
t.Logf(" duration=%d, transfers=%d, cost=%d", r.TotalDuration, r.TotalTransfers, r.Cost)
}
// Verify no route is dominated
for i, r1 := range results {
for j, r2 := range results {
if i == j {
continue
}
if r2.TotalDuration <= r1.TotalDuration &&
r2.TotalTransfers <= r1.TotalTransfers &&
r2.Cost <= r1.Cost &&
(r2.TotalDuration < r1.TotalDuration ||
r2.TotalTransfers < r1.TotalTransfers ||
r2.Cost < r1.Cost) {
t.Errorf("route %d dominated by route %d in fewest_transfers mode", i, j)
}
}
}
})
t.Run("cheapest mode sorts by cost first", func(t *testing.T) {
opts := SearchOptions{MaxTransfers: 3, RankingMode: "cheapest"}
results := graph.FindRoutesPareto("s1", "s8", opts)
if len(results) == 0 {
t.Fatal("expected at least one Pareto-optimal route with cheapest mode")
}
t.Logf("Routes (cheapest mode):")
for _, r := range results {
t.Logf(" duration=%d, transfers=%d, cost=%d", r.TotalDuration, r.TotalTransfers, r.Cost)
}
// Verify no route is dominated
for i, r1 := range results {
for j, r2 := range results {
if i == j {
continue
}
if r2.TotalDuration <= r1.TotalDuration &&
r2.TotalTransfers <= r1.TotalTransfers &&
r2.Cost <= r1.Cost &&
(r2.TotalDuration < r1.TotalDuration ||
r2.TotalTransfers < r1.TotalTransfers ||
r2.Cost < r1.Cost) {
t.Errorf("route %d dominated by route %d in cheapest mode", i, j)
}
}
}
})
}
func TestLazyExpansionDepthLimit(t *testing.T) {
graph := NewGraph()
// Create 7 stations: s1, s2, s3, s4, s5, s6, s7
for i := 0; i < 7; i++ {
graph.AddNode(&Node{ID: fmt.Sprintf("s%d", i+1), Type: NodeTypeStation, Name: fmt.Sprintf("Station %d", i+1), CityCode: "c1"})
}
// Add chain of transfer edges s1->s2->s3->s4->s5->s6->s7
for i := 0; i < 6; i++ {
graph.AddEdge(&Edge{
From: graph.Nodes()[i],
To: graph.Nodes()[i+1],
Kind: EdgeKindReal,
Duration: 100,
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: true,
})
}
// Test with MaxTransfers=2: should only find routes with <= 2 transfers
opts2 := SearchOptions{MaxTransfers: 2}
results2 := graph.FindRoute("s1", "s7", opts2)
if results2 != nil {
t.Logf("MaxTransfers=2: found route with %d transfers", results2.TotalTransfers)
for _, leg := range results2.Legs {
t.Logf(" Leg: %s -> %s (isTransfer=%v)", leg.From.Name, leg.To.Name, leg.IsTransfer)
}
// With MaxTransfers=2, a chain of 6 transfers (s1->...->s7) should not be found
if results2.TotalTransfers > 2 {
t.Errorf("expected <= 2 transfers with MaxTransfers=2, got %d", results2.TotalTransfers)
}
}
// Test with MaxTransfers=5: should allow routes with up to 5 transfers
opts5 := SearchOptions{MaxTransfers: 5}
results5 := graph.FindRoute("s1", "s7", opts5)
if results5 != nil {
t.Logf("MaxTransfers=5: found route with %d transfers", results5.TotalTransfers)
if results5.TotalTransfers > 5 {
t.Errorf("expected <= 5 transfers with MaxTransfers=5, got %d", results5.TotalTransfers)
}
} else {
t.Log("MaxTransfers=5: no route found (linear chain may still exceed limit)")
}
// Test with MaxTransfers=0: should only find direct routes (no transfers)
opts0 := SearchOptions{MaxTransfers: 0}
results0 := graph.FindRoute("s1", "s7", opts0)
if results0 != nil {
t.Logf("MaxTransfers=0: found route with %d transfers", results0.TotalTransfers)
for _, leg := range results0.Legs {
t.Logf(" Leg: %s -> %s (isTransfer=%v)", leg.From.Name, leg.To.Name, leg.IsTransfer)
}
if results0.TotalTransfers != 0 {
t.Errorf("expected 0 transfers with MaxTransfers=0, got %d", results0.TotalTransfers)
}
} else {
t.Log("MaxTransfers=0: no direct route s1->s7 found (only chain edges exist)")
}
}