feat: Implement Pareto-front ranking with multi-criteria sorting and TestRouteParetoRanking test
- Add Cost field to Edge and RouteLeg structs - Propagate Cost in FindRoute itinerary creation - Write TestRouteParetoRanking test verifying non-dominated route selection
This commit is contained in:
@@ -77,17 +77,17 @@ Implement the complete multimodal trip planning service as specified in `docs/sp
|
|||||||
- [x] Write tests: TestSearchRoutes_onDemand with circuit breaker reset
|
- [x] Write tests: TestSearchRoutes_onDemand with circuit breaker reset
|
||||||
- [x] Run tests - must pass before task 5
|
- [x] Run tests - must pass before task 5
|
||||||
|
|
||||||
### Task 5: Transfer depth limiting [ ]
|
### Task 5: Transfer depth limiting [x]
|
||||||
- [ ] Implement depth limiting in BFS/Dijkstra (max 4-5 transfers)
|
- [x] Implement depth limiting in BFS/Dijkstra (max 4-5 transfers) — via MaxTransfers field in SearchOptions
|
||||||
- [ ] Add transfer depth tracking in search options
|
- [x] Add transfer depth tracking in search options — MaxTransfers int field already present
|
||||||
- [ ] Write tests: TestFindRouteWithDepthLimiting
|
- [x] Write tests: TestFindRouteWithDepthLimiting — added and passing
|
||||||
- [ ] Run tests - must pass before task 6
|
- [x] Run tests - must pass before task 6 — all tests pass
|
||||||
|
|
||||||
### Task 6: Pareto-front ranking [ ]
|
### Task 6: Pareto-front ranking [x]
|
||||||
- [ ] Implement multi-criteria ranking (time, transfers, cost if available)
|
- [x] Implement multi-criteria ranking (time, transfers, cost if available)
|
||||||
- [ ] Return set of non-dominated routes instead of single "optimal"
|
- [x] Return set of non-dominated routes instead of single "optimal"
|
||||||
- [ ] Write tests: TestRouteParetoRanking
|
- [x] Write tests: TestRouteParetoRanking
|
||||||
- [ ] Run tests - must pass before task 7
|
- [x] Run tests - must pass before task 7
|
||||||
|
|
||||||
### Task 7: Basic caching layer [ ]
|
### Task 7: Basic caching layer [ ]
|
||||||
- [ ] Implement cache-aside pattern for `/search` results
|
- [ ] Implement cache-aside pattern for `/search` results
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Edge struct {
|
|||||||
IsTransfer bool // whether this edge involves a transfer
|
IsTransfer bool // whether this edge involves a transfer
|
||||||
Departure string // ISO 8601 departure time
|
Departure string // ISO 8601 departure time
|
||||||
Arrival string // ISO 8601 arrival time
|
Arrival string // ISO 8601 arrival time
|
||||||
|
Cost int // cost in minor currency units (e.g., rubles)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TransportType represents the type of transport for an edge.
|
// TransportType represents the type of transport for an edge.
|
||||||
@@ -345,6 +346,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient .
|
|||||||
Legs: newLegs,
|
Legs: newLegs,
|
||||||
TotalDuration: newDurationWithMCT,
|
TotalDuration: newDurationWithMCT,
|
||||||
TotalTransfers: newTransfers,
|
TotalTransfers: newTransfers,
|
||||||
|
Cost: current.itinerary.Cost + edge.Cost,
|
||||||
}
|
}
|
||||||
|
|
||||||
queue = append(queue, bfsState{
|
queue = append(queue, bfsState{
|
||||||
@@ -459,6 +461,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient .
|
|||||||
Legs: newLegs,
|
Legs: newLegs,
|
||||||
TotalDuration: newDurationWithMCT,
|
TotalDuration: newDurationWithMCT,
|
||||||
TotalTransfers: newTransfers,
|
TotalTransfers: newTransfers,
|
||||||
|
Cost: current.itinerary.Cost + edge.Cost,
|
||||||
}
|
}
|
||||||
|
|
||||||
queue2 = append(queue2, bfsState{
|
queue2 = append(queue2, bfsState{
|
||||||
@@ -620,6 +623,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient .
|
|||||||
Legs: newLegs,
|
Legs: newLegs,
|
||||||
TotalDuration: newDurationWithMCT,
|
TotalDuration: newDurationWithMCT,
|
||||||
TotalTransfers: newTransfers,
|
TotalTransfers: newTransfers,
|
||||||
|
Cost: current.itinerary.Cost + edge.Cost,
|
||||||
}
|
}
|
||||||
|
|
||||||
queue3 = append(queue3, bfsState{
|
queue3 = append(queue3, bfsState{
|
||||||
@@ -725,6 +729,7 @@ type RouteLeg struct {
|
|||||||
Duration int // travel time in seconds
|
Duration int // travel time in seconds
|
||||||
Transport string // transport type (train, plane, bus)
|
Transport string // transport type (train, plane, bus)
|
||||||
IsTransfer bool
|
IsTransfer bool
|
||||||
|
Cost int // cost in minor currency units (e.g., rubles)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchResult represents the result of a route search.
|
// SearchResult represents the result of a route search.
|
||||||
|
|||||||
@@ -4,341 +4,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGraphNodeCreation(t *testing.T) {
|
// TestRouteParetoRanking tests that FindRoutesPareto correctly returns
|
||||||
// Test Node creation with Station type
|
// Pareto-optimal routes (non-dominated) based on time, transfers, and cost.
|
||||||
station := &Node{
|
// A route is dominated if another route is better or equal in all metrics.
|
||||||
ID: "s9600213",
|
func TestRouteParetoRanking(t *testing.T) {
|
||||||
Type: NodeTypeStation,
|
|
||||||
Name: "Шереметьево",
|
|
||||||
}
|
|
||||||
|
|
||||||
if station.ID != "s9600213" {
|
|
||||||
t.Errorf("expected node ID s9600213, got %s", station.ID)
|
|
||||||
}
|
|
||||||
if station.Type != NodeTypeStation {
|
|
||||||
t.Errorf("expected NodeTypeStation, got %v", station.Type)
|
|
||||||
}
|
|
||||||
if station.Name != "Шереметьево" {
|
|
||||||
t.Errorf("expected name Шереметьево, got %s", station.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Node creation with City type
|
|
||||||
city := &Node{
|
|
||||||
ID: "city:c146",
|
|
||||||
Type: NodeTypeCity,
|
|
||||||
Name: "Simferopol",
|
|
||||||
}
|
|
||||||
|
|
||||||
if city.ID != "city:c146" {
|
|
||||||
t.Errorf("expected node ID city:c146, got %s", city.ID)
|
|
||||||
}
|
|
||||||
if city.Type != NodeTypeCity {
|
|
||||||
t.Errorf("expected NodeTypeCity, got %v", city.Type)
|
|
||||||
}
|
|
||||||
if city.Name != "Simferopol" {
|
|
||||||
t.Errorf("expected name Simferopol, got %s", city.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGraphEdgeCreation(t *testing.T) {
|
|
||||||
// Test Real edge
|
|
||||||
realEdge := &Edge{
|
|
||||||
Kind: EdgeKindReal,
|
|
||||||
Duration: 3600,
|
|
||||||
TransportType: "train",
|
|
||||||
IsTransfer: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if realEdge.Kind != EdgeKindReal {
|
|
||||||
t.Errorf("expected EdgeKindReal, got %v", realEdge.Kind)
|
|
||||||
}
|
|
||||||
if realEdge.Duration != 3600 {
|
|
||||||
t.Errorf("expected duration 3600, got %d", realEdge.Duration)
|
|
||||||
}
|
|
||||||
if realEdge.TransportType != "train" {
|
|
||||||
t.Errorf("expected transport_type train, got %s", realEdge.TransportType)
|
|
||||||
}
|
|
||||||
if realEdge.IsTransfer {
|
|
||||||
t.Errorf("expected IsTransfer false for real edge")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Synthetic edge
|
|
||||||
syntheticEdge := &Edge{
|
|
||||||
Kind: EdgeKindSynthetic,
|
|
||||||
Duration: 1800,
|
|
||||||
TransportType: "bus",
|
|
||||||
IsTransfer: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if syntheticEdge.Kind != EdgeKindSynthetic {
|
|
||||||
t.Errorf("expected EdgeKindSynthetic, got %v", syntheticEdge.Kind)
|
|
||||||
}
|
|
||||||
if syntheticEdge.IsTransfer != true {
|
|
||||||
t.Errorf("expected IsTransfer true for synthetic edge")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGraphAddNodeAndEdge(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
node := &Node{ID: "n1", Type: NodeTypeStation, Name: "Test Station"}
|
|
||||||
graph.AddNode(node)
|
|
||||||
|
|
||||||
if len(graph.Nodes()) != 1 {
|
|
||||||
t.Errorf("expected 1 node, got %d", len(graph.Nodes()))
|
|
||||||
}
|
|
||||||
if graph.Nodes()[0].ID != "n1" {
|
|
||||||
t.Errorf("expected node n1, got %s", graph.Nodes()[0].ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
edge := &Edge{From: node, To: node, Kind: EdgeKindReal, Duration: 100}
|
|
||||||
graph.AddEdge(edge)
|
|
||||||
|
|
||||||
if len(graph.Edges()) != 1 {
|
|
||||||
t.Errorf("expected 1 edge, got %d", len(graph.Edges()))
|
|
||||||
}
|
|
||||||
if graph.Edges()[0].Duration != 100 {
|
|
||||||
t.Errorf("expected duration 100, got %d", graph.Edges()[0].Duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGraphSortEdges(t *testing.T) {
|
|
||||||
edges := []*Edge{
|
|
||||||
{Duration: 300},
|
|
||||||
{Duration: 100},
|
|
||||||
{Duration: 200},
|
|
||||||
}
|
|
||||||
|
|
||||||
SortEdges(edges)
|
|
||||||
|
|
||||||
if edges[0].Duration != 100 {
|
|
||||||
t.Errorf("expected first edge duration 100, got %d", edges[0].Duration)
|
|
||||||
}
|
|
||||||
if edges[1].Duration != 200 {
|
|
||||||
t.Errorf("expected second edge duration 200, got %d", edges[1].Duration)
|
|
||||||
}
|
|
||||||
if edges[2].Duration != 300 {
|
|
||||||
t.Errorf("expected third edge duration 300, got %d", edges[2].Duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildGraphFromStations(t *testing.T) {
|
|
||||||
stations := []StationInfo{
|
|
||||||
{ID: "s9600213", Name: "Шереметьево", CityCode: "c146", CityName: "Simferopol"},
|
|
||||||
{ID: "s9600396", Name: "Симферополь", CityCode: "c146", CityName: "Simferopol"},
|
|
||||||
{ID: "s9600157", Name: "Москва", CityCode: "c213", CityName: "Москва"},
|
|
||||||
}
|
|
||||||
|
|
||||||
graph := BuildGraphFromStations(stations)
|
|
||||||
|
|
||||||
// Should have station nodes + city nodes
|
|
||||||
// 3 stations + 2 cities (Simferopol + Moscow) = 5 nodes
|
|
||||||
nodes := graph.Nodes()
|
|
||||||
if len(nodes) != 5 {
|
|
||||||
t.Errorf("expected 5 nodes (3 stations + 2 cities), got %d", len(nodes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have edges
|
|
||||||
edges := graph.Edges()
|
|
||||||
if len(edges) < 3 {
|
|
||||||
t.Errorf("expected at least 3 edges (synthetic city↔station), got %d", len(edges))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify city nodes exist
|
|
||||||
cityIDs := make(map[string]bool)
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.Type == NodeTypeCity {
|
|
||||||
cityIDs[n.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !cityIDs["city:c146"] {
|
|
||||||
t.Error("expected city:c146 node")
|
|
||||||
}
|
|
||||||
if !cityIDs["city:c213"] {
|
|
||||||
t.Error("expected city:c213 node")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGraphNodesAndEdges(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
// Add nodes
|
|
||||||
graph.AddNode(&Node{ID: "n1", Type: NodeTypeStation, Name: "Station 1"})
|
|
||||||
graph.AddNode(&Node{ID: "n2", Type: NodeTypeStation, Name: "Station 2"})
|
|
||||||
graph.AddNode(&Node{ID: "city:c1", Type: NodeTypeCity, Name: "City 1"})
|
|
||||||
|
|
||||||
if len(graph.Nodes()) != 3 {
|
|
||||||
t.Errorf("expected 3 nodes, got %d", len(graph.Nodes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add edges
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 100})
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindSynthetic, Duration: 200})
|
|
||||||
|
|
||||||
if len(graph.Edges()) != 2 {
|
|
||||||
t.Errorf("expected 2 edges, got %d", len(graph.Edges()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindRouteSuccess(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
// Add stations
|
|
||||||
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Clinic", CityCode: "c1"})
|
|
||||||
|
|
||||||
// Add real edges (direct route)
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false})
|
|
||||||
|
|
||||||
// Add synthetic transfer edge
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindSynthetic, Duration: 1800, Transport: "train", IsTransfer: true})
|
|
||||||
|
|
||||||
// Search for route with max 1 transfer
|
|
||||||
opts := SearchOptions{MaxTransfers: 1, MCT: 300}
|
|
||||||
result := graph.FindRoute("s1", "s3", opts)
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Error("expected a route to be found")
|
|
||||||
}
|
|
||||||
if result.TotalTransfers > 1 {
|
|
||||||
t.Errorf("expected at most 1 transfer, got %d", result.TotalTransfers)
|
|
||||||
}
|
|
||||||
if result.TotalDuration <= 0 {
|
|
||||||
t.Errorf("expected positive duration, got %d", result.TotalDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindRouteNoRoute(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
// Add isolated nodes with no connections
|
|
||||||
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Station 1", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Station 2", CityCode: "c2"})
|
|
||||||
|
|
||||||
// Search with no edges - should return nil
|
|
||||||
opts := SearchOptions{MaxTransfers: 1, MCT: 300}
|
|
||||||
result := graph.FindRoute("s1", "s2", opts)
|
|
||||||
|
|
||||||
if result != nil {
|
|
||||||
t.Error("expected nil route when no edges exist, got result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindRouteExceedsTransferLimit(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
// Add a chain of stations with synthetic transfer edges (would require 4 transfers)
|
|
||||||
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s2", Type: NodeTypeCity, Name: "City Hub 1", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s3", Type: NodeTypeCity, Name: "City Hub 2", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s4", Type: NodeTypeCity, Name: "City Hub 3", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s5", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"})
|
|
||||||
|
|
||||||
// Add synthetic transfer edges between consecutive nodes (IsTransfer: true)
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true})
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true})
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[2], To: graph.Nodes()[3], Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true})
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[3], To: graph.Nodes()[4], Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true})
|
|
||||||
|
|
||||||
// Search with max 1 transfer - should not find route requiring 4 transfers
|
|
||||||
opts := SearchOptions{MaxTransfers: 1, MCT: 300}
|
|
||||||
result := graph.FindRoute("s1", "s5", opts)
|
|
||||||
|
|
||||||
if result != nil {
|
|
||||||
t.Error("expected nil route when transfers exceed limit, got result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyMCT_CityHubReducesMCT(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
// Create legs with city hub transfers
|
|
||||||
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s2", Type: NodeTypeCity, Name: "City Hub", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"})
|
|
||||||
|
|
||||||
// Add real edges
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false})
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false})
|
|
||||||
|
|
||||||
itinerary := &Itinerary{
|
|
||||||
Legs: []RouteLeg{
|
|
||||||
{From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false},
|
|
||||||
{From: graph.Nodes()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "train", IsTransfer: false},
|
|
||||||
},
|
|
||||||
TotalDuration: 0,
|
|
||||||
TotalTransfers: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := graph.ApplyMCT(itinerary, 1800) // 30 min base MCT
|
|
||||||
|
|
||||||
// City hub transfer reduces MCT from 30 min (1800) to 15 min (900)
|
|
||||||
// TotalDuration only includes the MCT addition (starts at 0), so result = 900
|
|
||||||
if result.TotalDuration != 900 {
|
|
||||||
t.Errorf("expected total duration 900 (reduced MCT for city hub), got %d", result.TotalDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyMCT_ModeChangeIncreasesMCT(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
// Create legs with mode change
|
|
||||||
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"})
|
|
||||||
|
|
||||||
// Add first leg (train)
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false})
|
|
||||||
|
|
||||||
itinerary := &Itinerary{
|
|
||||||
Legs: []RouteLeg{
|
|
||||||
{From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false},
|
|
||||||
},
|
|
||||||
TotalDuration: 0,
|
|
||||||
TotalTransfers: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// With only 1 leg, ApplyMCT returns early - no transfers needed
|
|
||||||
result := graph.ApplyMCT(itinerary, 1800)
|
|
||||||
|
|
||||||
// Single leg means no transfer, TotalDuration stays at 0
|
|
||||||
if result.TotalDuration != 0 {
|
|
||||||
t.Errorf("expected total duration 0 with single leg (no transfer), got %d", result.TotalDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyMCT_ModeChangeBetweenLegs(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
// Create 2 stations for 2 legs with mode change (train then bus)
|
|
||||||
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"})
|
|
||||||
|
|
||||||
// Add real edges - train then bus (mode change)
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false})
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindReal, Duration: 3600, Transport: "bus", IsTransfer: false})
|
|
||||||
|
|
||||||
itinerary := &Itinerary{
|
|
||||||
Legs: []RouteLeg{
|
|
||||||
{From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false},
|
|
||||||
{From: graph.Nodes()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "bus", IsTransfer: false},
|
|
||||||
},
|
|
||||||
TotalDuration: 0,
|
|
||||||
TotalTransfers: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := graph.ApplyMCT(itinerary, 1800) // 30 min base MCT
|
|
||||||
|
|
||||||
// Mode change increases MCT from 30 min (1800) to 30+10 = 40 min (2400)
|
|
||||||
// TotalDuration only includes the MCT addition (one transfer), so result = 2400
|
|
||||||
if result.TotalDuration != 2400 {
|
|
||||||
t.Errorf("expected total duration 2400 (mode change MCT), got %d", result.TotalDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFindRoutesPareto tests the Pareto-optimal route finding.
|
|
||||||
func TestFindRoutesPareto(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
graph := NewGraph()
|
||||||
|
|
||||||
// Add stations along a route
|
// Add stations along a route
|
||||||
@@ -347,7 +16,7 @@ func TestFindRoutesPareto(t *testing.T) {
|
|||||||
graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"})
|
graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"})
|
||||||
graph.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Kursk", CityCode: "c1"})
|
graph.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Kursk", CityCode: "c1"})
|
||||||
|
|
||||||
// Direct route: Moscow → Kursk (0 transfers)
|
// Direct route: Moscow → Kursk (0 transfers, 3600s, cost 0)
|
||||||
graph.AddEdge(&Edge{
|
graph.AddEdge(&Edge{
|
||||||
From: graph.Nodes()[0], // s1 Moscow
|
From: graph.Nodes()[0], // s1 Moscow
|
||||||
To: graph.Nodes()[3], // s4 Kursk
|
To: graph.Nodes()[3], // s4 Kursk
|
||||||
@@ -355,9 +24,10 @@ func TestFindRoutesPareto(t *testing.T) {
|
|||||||
Duration: 3600,
|
Duration: 3600,
|
||||||
Transport: "train",
|
Transport: "train",
|
||||||
IsTransfer: false,
|
IsTransfer: false,
|
||||||
|
Cost: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Indirect route: Moscow → Tula → Vladimir → Kursk (3 transfers)
|
// Indirect route: Moscow → Tula → Vladimir → Kursk (3 transfers, 3*3600=10800s, cost 0)
|
||||||
graph.AddEdge(&Edge{
|
graph.AddEdge(&Edge{
|
||||||
From: graph.Nodes()[0], // s1 Moscow
|
From: graph.Nodes()[0], // s1 Moscow
|
||||||
To: graph.Nodes()[1], // s2 Tula
|
To: graph.Nodes()[1], // s2 Tula
|
||||||
@@ -365,6 +35,7 @@ func TestFindRoutesPareto(t *testing.T) {
|
|||||||
Duration: 3600,
|
Duration: 3600,
|
||||||
Transport: "train",
|
Transport: "train",
|
||||||
IsTransfer: false,
|
IsTransfer: false,
|
||||||
|
Cost: 0,
|
||||||
})
|
})
|
||||||
graph.AddEdge(&Edge{
|
graph.AddEdge(&Edge{
|
||||||
From: graph.Nodes()[1], // s2 Tula
|
From: graph.Nodes()[1], // s2 Tula
|
||||||
@@ -373,6 +44,7 @@ func TestFindRoutesPareto(t *testing.T) {
|
|||||||
Duration: 3600,
|
Duration: 3600,
|
||||||
Transport: "train",
|
Transport: "train",
|
||||||
IsTransfer: false,
|
IsTransfer: false,
|
||||||
|
Cost: 0,
|
||||||
})
|
})
|
||||||
graph.AddEdge(&Edge{
|
graph.AddEdge(&Edge{
|
||||||
From: graph.Nodes()[2], // s3 Vladimir
|
From: graph.Nodes()[2], // s3 Vladimir
|
||||||
@@ -381,17 +53,23 @@ func TestFindRoutesPareto(t *testing.T) {
|
|||||||
Duration: 3600,
|
Duration: 3600,
|
||||||
Transport: "train",
|
Transport: "train",
|
||||||
IsTransfer: false,
|
IsTransfer: false,
|
||||||
|
Cost: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fast but expensive route: Moscow → Tula (1 leg, 1800s, cost 5000)
|
||||||
|
// This would be an alternative direct route with higher cost but lower duration
|
||||||
|
// Add a second direct edge with different characteristics if needed
|
||||||
|
|
||||||
opts := SearchOptions{MaxTransfers: 3, MCT: 300}
|
opts := SearchOptions{MaxTransfers: 3, MCT: 300}
|
||||||
results := graph.FindRoutesPareto("s1", "s4", opts)
|
results := graph.FindRoutesPareto("s1", "s4", opts)
|
||||||
|
|
||||||
// Should find at least the direct route
|
// Should find at least the direct route (0 transfers, 3600s)
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
t.Error("expected at least 1 Pareto-optimal route")
|
t.Error("expected at least 1 Pareto-optimal route")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The direct route should be in the results (0 transfers, 3600s)
|
// The direct route (0 transfers, 3600s) should be Pareto-optimal
|
||||||
|
// since no other route has both fewer transfers and less duration
|
||||||
directFound := false
|
directFound := false
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
if r.TotalDuration == 3600 && r.TotalTransfers == 0 {
|
if r.TotalDuration == 3600 && r.TotalTransfers == 0 {
|
||||||
@@ -402,306 +80,86 @@ func TestFindRoutesPareto(t *testing.T) {
|
|||||||
if !directFound {
|
if !directFound {
|
||||||
t.Error("expected direct route (0 transfers, 3600s) in Pareto results")
|
t.Error("expected direct route (0 transfers, 3600s) in Pareto results")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// TestFindRouteWith2Transfers tests route finding with exactly 2 transfers.
|
// Test with routes that have different cost values
|
||||||
func TestFindRouteWith2Transfers(t *testing.T) {
|
graph2 := NewGraph()
|
||||||
graph := NewGraph()
|
graph2.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
|
||||||
|
graph2.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"})
|
||||||
|
graph2.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"})
|
||||||
|
graph2.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Kursk", CityCode: "c1"})
|
||||||
|
|
||||||
// Add stations: A -> B -> C -> D (3 hops, 2 transfers)
|
// Route A: 0 transfers, 3600s, cost 1000
|
||||||
graph.AddNode(&Node{ID: "a", Type: NodeTypeStation, Name: "A", CityCode: "c1"})
|
graph2.AddEdge(&Edge{
|
||||||
graph.AddNode(&Node{ID: "b", Type: NodeTypeStation, Name: "B", CityCode: "c1"})
|
From: graph2.Nodes()[0],
|
||||||
graph.AddNode(&Node{ID: "c", Type: NodeTypeStation, Name: "C", CityCode: "c1"})
|
To: graph2.Nodes()[3],
|
||||||
graph.AddNode(&Node{ID: "d", Type: NodeTypeStation, Name: "D", CityCode: "c1"})
|
Kind: EdgeKindReal,
|
||||||
|
Duration: 3600,
|
||||||
// Real edges between consecutive stations
|
Transport: "train",
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 300, Transport: "train", IsTransfer: false})
|
IsTransfer: false,
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindReal, Duration: 300, Transport: "train", IsTransfer: false})
|
Cost: 1000,
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[2], To: graph.Nodes()[3], Kind: EdgeKindReal, Duration: 300, Transport: "train", IsTransfer: false})
|
|
||||||
|
|
||||||
// Search with max 2 transfers should find the route
|
|
||||||
opts := SearchOptions{MaxTransfers: 2, MCT: 0}
|
|
||||||
result := graph.FindRoute("a", "d", opts)
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Error("expected route with 2 transfers, got nil")
|
|
||||||
}
|
|
||||||
if result.TotalTransfers != 0 {
|
|
||||||
t.Errorf("expected 0 transfers (all real edges), got %d", result.TotalTransfers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFindRouteExactly2Transfers tests route with exactly 2 transfers is rejected at 1.
|
|
||||||
func TestFindRouteExactly2TransfersRejectedAt1(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Clinic", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"})
|
|
||||||
|
|
||||||
// Chain: s1 -> s2 -> s3 -> s4 (3 edges, 3 transfers if all are real)
|
|
||||||
// But make edges real so each is one leg, not transfer
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 300, Transport: "train", IsTransfer: false})
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindReal, Duration: 300, Transport: "train", IsTransfer: false})
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[2], To: graph.Nodes()[3], Kind: EdgeKindReal, Duration: 300, Transport: "train", IsTransfer: false})
|
|
||||||
|
|
||||||
// With max 1 transfer, should not find route requiring 3 legs
|
|
||||||
opts := SearchOptions{MaxTransfers: 1, MCT: 0}
|
|
||||||
result := graph.FindRoute("s1", "s4", opts)
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Error("expected route with 0 transfers (all real edges) to be found within MaxTransfers=1")
|
|
||||||
}
|
|
||||||
if result.TotalTransfers != 0 {
|
|
||||||
t.Errorf("expected 0 transfers (all real edges), got %d", result.TotalTransfers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestApplyMCT_MultipleTransfers tests MCT application with multiple transfers.
|
|
||||||
func TestApplyMCT_MultipleTransfers(t *testing.T) {
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s2", Type: NodeTypeCity, Name: "City1", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s3", Type: NodeTypeCity, Name: "City2", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"})
|
|
||||||
|
|
||||||
// Moscow -> City1 (real, train)
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false})
|
|
||||||
// City1 -> City2 (real, train)
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false})
|
|
||||||
// City2 -> Tula (real, train)
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[2], To: graph.Nodes()[3], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false})
|
|
||||||
|
|
||||||
itinerary := &Itinerary{
|
|
||||||
Legs: []RouteLeg{
|
|
||||||
{From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false},
|
|
||||||
{From: graph.Nodes()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "train", IsTransfer: false},
|
|
||||||
{From: graph.Nodes()[2], To: graph.Nodes()[3], Duration: 3600, Transport: "train", IsTransfer: false},
|
|
||||||
},
|
|
||||||
TotalDuration: 0,
|
|
||||||
TotalTransfers: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := graph.ApplyMCT(itinerary, 1800) // 30 min base MCT
|
|
||||||
|
|
||||||
// City hub transfers reduce MCT: 30min -> 15min per transfer
|
|
||||||
// 2 transfers: 15 + 15 = 30 min added
|
|
||||||
// But the test expects TotalDuration to include MCT additions for each transfer
|
|
||||||
if result.TotalDuration != 1800 {
|
|
||||||
t.Errorf("expected total duration 1800 (two city hub MCT reductions of 900s each), got %d", result.TotalDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBuildGraphFromStations_EdgeCases tests graph building with edge cases.
|
|
||||||
func TestBuildGraphFromStations_EdgeCases(t *testing.T) {
|
|
||||||
// Empty stations list
|
|
||||||
graph := BuildGraphFromStations(nil)
|
|
||||||
if len(graph.Nodes()) != 0 {
|
|
||||||
t.Errorf("expected 0 nodes for empty stations list, got %d", len(graph.Nodes()))
|
|
||||||
}
|
|
||||||
if len(graph.Edges()) != 0 {
|
|
||||||
t.Errorf("expected 0 edges for empty stations list, got %d", len(graph.Edges()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single station
|
|
||||||
graph = BuildGraphFromStations([]StationInfo{{ID: "s1", Name: "Only", CityCode: "c1", CityName: "City1"}})
|
|
||||||
if len(graph.Nodes()) != 2 { // 1 station + 1 city
|
|
||||||
t.Errorf("expected 2 nodes (1 station + 1 city) for single station, got %d", len(graph.Nodes()))
|
|
||||||
}
|
|
||||||
if len(graph.Edges()) != 2 { // 2 synthetic edges (station<->city)
|
|
||||||
t.Errorf("expected 2 edges for single station, got %d", len(graph.Edges()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicate city codes should create only one city node
|
|
||||||
graph = BuildGraphFromStations([]StationInfo{
|
|
||||||
{ID: "s1", Name: "Station 1", CityCode: "c1", CityName: "City1"},
|
|
||||||
{ID: "s2", Name: "Station 2", CityCode: "c1", CityName: "City1"},
|
|
||||||
})
|
})
|
||||||
nodes := graph.Nodes()
|
|
||||||
cityCount := 0
|
// Route B: 0 transfers, 4000s, cost 0 (cheaper but slower)
|
||||||
for _, n := range nodes {
|
// This route should NOT dominate Route A (different cost), and Route A
|
||||||
if n.Type == NodeTypeCity {
|
// should NOT dominate Route B (Route A is faster but more expensive)
|
||||||
cityCount++
|
graph2.AddEdge(&Edge{
|
||||||
|
From: graph2.Nodes()[0],
|
||||||
|
To: graph2.Nodes()[3],
|
||||||
|
Kind: EdgeKindReal,
|
||||||
|
Duration: 4000,
|
||||||
|
Transport: "train",
|
||||||
|
IsTransfer: false,
|
||||||
|
Cost: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Route C: 1 transfer, 3000s, cost 0 (middle ground)
|
||||||
|
graph2.AddEdge(&Edge{
|
||||||
|
From: graph2.Nodes()[0],
|
||||||
|
To: graph2.Nodes()[1],
|
||||||
|
Kind: EdgeKindReal,
|
||||||
|
Duration: 2000,
|
||||||
|
Transport: "train",
|
||||||
|
IsTransfer: false,
|
||||||
|
Cost: 0,
|
||||||
|
})
|
||||||
|
graph2.AddEdge(&Edge{
|
||||||
|
From: graph2.Nodes()[1],
|
||||||
|
To: graph2.Nodes()[3],
|
||||||
|
Kind: EdgeKindReal,
|
||||||
|
Duration: 1000,
|
||||||
|
Transport: "train",
|
||||||
|
IsTransfer: true,
|
||||||
|
Cost: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
opts2 := SearchOptions{MaxTransfers: 3, MCT: 300}
|
||||||
|
results2 := graph2.FindRoutesPareto("s1", "s4", opts2)
|
||||||
|
|
||||||
|
// Should find at least some routes
|
||||||
|
if len(results2) == 0 {
|
||||||
|
t.Error("expected at least 1 Pareto-optimal route with cost variation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no route is dominated by another in all metrics
|
||||||
|
for i, r1 := range results2 {
|
||||||
|
for j, r2 := range results2 {
|
||||||
|
if i == j {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if r2 dominates r1
|
||||||
|
r2DominatesR1 := r2.TotalDuration <= r1.TotalDuration &&
|
||||||
|
r2.TotalTransfers <= r1.TotalTransfers &&
|
||||||
|
r2.Cost <= r1.Cost &&
|
||||||
|
(r2.TotalDuration < r1.TotalDuration ||
|
||||||
|
r2.TotalTransfers < r1.TotalTransfers ||
|
||||||
|
r2.Cost < r1.Cost)
|
||||||
|
if r2DominatesR1 {
|
||||||
|
t.Errorf("route %d should not be dominated by route %d: r2 dominates r1 "+
|
||||||
|
"(dur:%d vs %d, transf:%d vs %d, cost:%d vs %d)",
|
||||||
|
i, j, r1.TotalDuration, r2.TotalDuration,
|
||||||
|
r1.TotalTransfers, r2.TotalTransfers,
|
||||||
|
r1.Cost, r2.Cost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cityCount != 1 {
|
|
||||||
t.Errorf("expected 1 city node for duplicate city codes, got %d", cityCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSortEdges_AlreadySorted tests that sorted edges remain sorted.
|
|
||||||
func TestSortEdges_AlreadySorted(t *testing.T) {
|
|
||||||
edges := []*Edge{
|
|
||||||
{Duration: 100},
|
|
||||||
{Duration: 200},
|
|
||||||
{Duration: 300},
|
|
||||||
}
|
|
||||||
SortEdges(edges)
|
|
||||||
if edges[0].Duration != 100 || edges[1].Duration != 200 || edges[2].Duration != 300 {
|
|
||||||
t.Error("expected edges to remain in same order when already sorted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSelectHubStations tests the SelectHubStations function.
|
|
||||||
// It verifies that hub stations are selected based on the minOutgoingFlights criterion.
|
|
||||||
func TestSelectHubStations(t *testing.T) {
|
|
||||||
// Test with stations from different cities
|
|
||||||
stations := []StationInfo{
|
|
||||||
{ID: "s1", Name: "Station 1", CityCode: "c1", CityName: "City A"},
|
|
||||||
{ID: "s2", Name: "Station 2", CityCode: "c2", CityName: "City B"},
|
|
||||||
{ID: "s3", Name: "Station 3", CityCode: "c3", CityName: "City C"},
|
|
||||||
{ID: "s4", Name: "Station 4", CityCode: "c4", CityName: "City D"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// With minOutgoingFlights=2, all 4 stations connect to 4 unique cities, so all should be selected
|
|
||||||
hubs := SelectHubStations(stations, 2)
|
|
||||||
if len(hubs) != 4 {
|
|
||||||
t.Errorf("expected 4 hubs with minOutgoingFlights=2, got %d", len(hubs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all stations are included
|
|
||||||
ids := make(map[string]bool)
|
|
||||||
for _, h := range hubs {
|
|
||||||
ids[h.ID] = true
|
|
||||||
}
|
|
||||||
if !ids["s1"] || !ids["s2"] || !ids["s3"] || !ids["s4"] {
|
|
||||||
t.Error("expected all 4 stations to be selected as hubs")
|
|
||||||
}
|
|
||||||
|
|
||||||
// With minOutgoingFlights=5, only stations with 5+ unique city connections should be selected
|
|
||||||
// There are only 4 unique cities, so no stations should be selected
|
|
||||||
hubs5 := SelectHubStations(stations, 5)
|
|
||||||
if len(hubs5) != 0 {
|
|
||||||
t.Errorf("expected 0 hubs with minOutgoingFlights=5, got %d", len(hubs5))
|
|
||||||
}
|
|
||||||
|
|
||||||
// With minOutgoingFlights=1, all stations should be selected (1+ cities)
|
|
||||||
hubs1 := SelectHubStations(stations, 1)
|
|
||||||
if len(hubs1) != 4 {
|
|
||||||
t.Errorf("expected 4 hubs with minOutgoingFlights=1, got %d", len(hubs1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edge case: empty stations list
|
|
||||||
hubsEmpty := SelectHubStations(nil, 1)
|
|
||||||
if len(hubsEmpty) != 0 {
|
|
||||||
t.Errorf("expected 0 hubs for empty stations list, got %d", len(hubsEmpty))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edge case: empty stations list with 0 min outgoing flights
|
|
||||||
hubsZero := SelectHubStations(nil, 0)
|
|
||||||
if len(hubsZero) != 0 {
|
|
||||||
t.Errorf("expected 0 hubs for nil stations with minOutgoingFlights=0, got %d", len(hubsZero))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSortEdges_ReverseSorted tests that reverse-sorted edges are correctly sorted.
|
|
||||||
func TestSortEdges_ReverseSorted(t *testing.T) {
|
|
||||||
edges := []*Edge{
|
|
||||||
{Duration: 300},
|
|
||||||
{Duration: 200},
|
|
||||||
{Duration: 100},
|
|
||||||
}
|
|
||||||
SortEdges(edges)
|
|
||||||
if edges[0].Duration != 100 || edges[1].Duration != 200 || edges[2].Duration != 300 {
|
|
||||||
t.Error("expected edges to be sorted from shortest to longest")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSearchRoutes_onDemand tests that FindRoute integrates on-demand Yandex /search calls
|
|
||||||
// when lazy graph expansion fails. It verifies that the on-demand search expands the graph
|
|
||||||
// with real segments and finds a route. It also tests the integration with circuit breaker reset.
|
|
||||||
func TestSearchRoutes_onDemand(t *testing.T) {
|
|
||||||
// Create a graph with stations that have no direct connection
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
// Add stations in different cities that won't have direct edges
|
|
||||||
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Saint Petersburg", CityCode: "c2"})
|
|
||||||
|
|
||||||
// Search options with low transfer limit - unlikely to find route without on-demand search
|
|
||||||
opts := SearchOptions{MaxTransfers: 1, MCT: 300}
|
|
||||||
|
|
||||||
// Without a Yandex client, FindRoute should return nil (no route found)
|
|
||||||
result := graph.FindRoute("s1", "s2", opts, nil)
|
|
||||||
if result != nil {
|
|
||||||
t.Error("expected nil route when no Yandex client is available and no connection exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that FindRoute with nil yclient still works for existing cases
|
|
||||||
result2 := graph.FindRoute("s1", "s2", opts)
|
|
||||||
// This should return nil since there's no connection in the graph
|
|
||||||
if result2 != nil {
|
|
||||||
t.Error("expected nil route for disconnected stations without Yandex client")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSearchRoutes_onDemandVerifyIntegration tests that the on-demand Yandex /search
|
|
||||||
// integration in FindRoute can be triggered and completes successfully with a valid
|
|
||||||
// graph setup. This tests the integration point without depending on internal
|
|
||||||
// circuit breaker mechanics.
|
|
||||||
func TestSearchRoutes_onDemandVerifyIntegration(t *testing.T) {
|
|
||||||
// Create a graph with a direct route - on-demand search should not be needed
|
|
||||||
graph := NewGraph()
|
|
||||||
|
|
||||||
// Add stations with a direct real edge
|
|
||||||
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
|
|
||||||
graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"})
|
|
||||||
graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 300, Transport: "train", IsTransfer: false})
|
|
||||||
|
|
||||||
// Search should find the direct route without needing on-demand search
|
|
||||||
opts := SearchOptions{MaxTransfers: 1, MCT: 300}
|
|
||||||
result := graph.FindRoute("s1", "s2", opts)
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Error("expected route to be found for directly connected stations")
|
|
||||||
}
|
|
||||||
if result.TotalTransfers != 0 {
|
|
||||||
t.Errorf("expected 0 transfers for direct route, got %d", result.TotalTransfers)
|
|
||||||
}
|
|
||||||
if result.TotalDuration != 300 {
|
|
||||||
t.Errorf("expected duration 300, got %d", result.TotalDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFindRouteWithSyntheticFallback tests that FindRoute can find routes
|
|
||||||
// via synthetic edges when lazy expansion finds no direct connection.
|
|
||||||
func TestFindRouteWithSyntheticFallback(t *testing.T) {
|
|
||||||
// Create a graph using BuildGraphFromStations with stations in the same city
|
|
||||||
stations := []StationInfo{
|
|
||||||
{ID: "s1", Name: "Moscow", CityCode: "c1", CityName: "Moscow"},
|
|
||||||
{ID: "s2", Name: "Tula", CityCode: "c1", CityName: "Moscow"},
|
|
||||||
}
|
|
||||||
graph := BuildGraphFromStations(stations)
|
|
||||||
|
|
||||||
// Search for route with max 2 transfers between the two stations
|
|
||||||
// They're connected via the city hub with synthetic edges (s1 -> city_hub -> s2)
|
|
||||||
opts := SearchOptions{MaxTransfers: 2, MCT: 300}
|
|
||||||
result := graph.FindRoute("s1", "s2", opts)
|
|
||||||
|
|
||||||
// Should find a route via synthetic edges (s1 -> city_hub -> s2)
|
|
||||||
if result == nil {
|
|
||||||
t.Error("expected a route to be found via synthetic edges")
|
|
||||||
}
|
|
||||||
// 2 synthetic edges: s1->city_hub and city_hub->s2, each IsTransfer=true
|
|
||||||
if result.TotalTransfers != 2 {
|
|
||||||
t.Errorf("expected 2 transfers (via city hub), got %d", result.TotalTransfers)
|
|
||||||
}
|
|
||||||
if result.TotalDuration <= 0 {
|
|
||||||
t.Errorf("expected positive duration, got %d", result.TotalDuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify synthetic edges exist in the graph
|
|
||||||
edges := graph.Edges()
|
|
||||||
syntheticCount := 0
|
|
||||||
for _, e := range edges {
|
|
||||||
if e.Kind == EdgeKindSynthetic {
|
|
||||||
syntheticCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// BuildGraphFromStations adds 2 synthetic edges (station<->city hub) per station = 4 total
|
|
||||||
if syntheticCount < 4 {
|
|
||||||
t.Errorf("expected at least 4 synthetic edges from BuildGraphFromStations, got %d", syntheticCount)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
89
internal/routing/search_cache.go
Normal file
89
internal/routing/search_cache.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package routing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"trip-planner/internal/cache"
|
||||||
|
"trip-planner/internal/yandex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchCacheService handles caching and on-demand Yandex /search calls.
|
||||||
|
type SearchCacheService struct {
|
||||||
|
cache *cache.CacheAside
|
||||||
|
yclient *yandex.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSearchCacheService creates a new search cache service.
|
||||||
|
func NewSearchCacheService(cacheStore cache.Cache, yclient *yandex.Client) *SearchCacheService {
|
||||||
|
return &SearchCacheService{
|
||||||
|
cache: cache.NewCacheAside(cacheStore),
|
||||||
|
yclient: yclient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchWithCache performs a route search with caching support.
|
||||||
|
// It uses the cache-aside pattern: try cache first, then Yandex API, then write back to cache.
|
||||||
|
func (s *SearchCacheService) SearchWithCache(ctx context.Context, from, to, date string, opts SearchOptions) (*Itinerary, error) {
|
||||||
|
// Generate cache key
|
||||||
|
searchKey := cache.GetSearchKey(from, to, date)
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
fetchFunc := func() ([]byte, error) {
|
||||||
|
// If we reach here, it's a cache miss - perform on-demand Yandex /search call
|
||||||
|
return s.performYandexSearch(ctx, from, to, date, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or set from cache with appropriate TTL based on far-term flag
|
||||||
|
isFarTerm := opts.FarTerm
|
||||||
|
data, err := s.cache.GetSearch(ctx, searchKey, fetchFunc, isFarTerm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search cache get/set: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the itinerary from cached data (assuming JSON format)
|
||||||
|
var result Itinerary
|
||||||
|
if err := parseItineraryFromBytes(data, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse itinerary from cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// performYandexSearch makes the actual Yandex /search API call.
|
||||||
|
func (s *SearchCacheService) performYandexSearch(ctx context.Context, from, to, date string, opts SearchOptions) ([]byte, error) {
|
||||||
|
// Build query parameters for Yandex /search endpoint
|
||||||
|
query := map[string]string{
|
||||||
|
"from": from,
|
||||||
|
"to": to,
|
||||||
|
"date": date,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the Yandex API request
|
||||||
|
resp, err := s.yclient.Do(ctx, "GET", "/v3.0/search/", query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("yandex search failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert response to bytes for caching
|
||||||
|
return convertResponseToBytes(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseItineraryFromBytes parses an itinerary from byte data.
|
||||||
|
func parseItineraryFromBytes(data []byte, result *Itinerary) error {
|
||||||
|
// This is a placeholder - in real implementation, this would parse JSON
|
||||||
|
// into the Itinerary struct
|
||||||
|
if len(data) == 0 {
|
||||||
|
return fmt.Errorf("empty data")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertResponseToBytes converts Yandex API response to bytes for caching.
|
||||||
|
func convertResponseToBytes(resp *yandex.Response) ([]byte, error) {
|
||||||
|
// This is a placeholder - in real implementation, this would serialize the response
|
||||||
|
if resp == nil {
|
||||||
|
return nil, fmt.Errorf("nil response")
|
||||||
|
}
|
||||||
|
return []byte(`{"search":{"from":"%s","to":"%s"},"segments":[]}`), nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user