package routing import ( "context" "testing" "trip-planner/internal/cache" "trip-planner/internal/yandex" ) func TestGraphNodeCreation(t *testing.T) { // Test Node creation with Station type station := &Node{ ID: "s9600213", 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 := NewGraphWithoutYandex() 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 := NewGraphWithoutYandex() // 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 := NewGraphWithoutYandex() // 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 := NewGraphWithoutYandex() // 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 := NewGraphWithoutYandex() // 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 := NewGraphWithoutYandex() // 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 := NewGraphWithoutYandex() // 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 := NewGraphWithoutYandex() // 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 := NewGraphWithoutYandex() // Add stations along a route 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"}) graph.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Kursk", CityCode: "c1"}) // Direct route: Moscow → Kursk (0 transfers) graph.AddEdge(&Edge{ From: graph.Nodes()[0], // s1 Moscow To: graph.Nodes()[3], // s4 Kursk Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false, }) // Indirect route: Moscow → Tula → Vladimir → Kursk (3 transfers) graph.AddEdge(&Edge{ From: graph.Nodes()[0], // s1 Moscow To: graph.Nodes()[1], // s2 Tula Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false, }) graph.AddEdge(&Edge{ From: graph.Nodes()[1], // s2 Tula To: graph.Nodes()[2], // s3 Vladimir Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false, }) graph.AddEdge(&Edge{ From: graph.Nodes()[2], // s3 Vladimir To: graph.Nodes()[3], // s4 Kursk Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false, }) opts := SearchOptions{MaxTransfers: 3, MCT: 300} results := graph.FindRoutesPareto("s1", "s4", opts) // Should find at least the direct route if len(results) == 0 { t.Error("expected at least 1 Pareto-optimal route") } // The direct route should be in the results (0 transfers, 3600s) directFound := false for _, r := range results { if r.TotalDuration == 3600 && r.TotalTransfers == 0 { directFound = true break } } if !directFound { t.Error("expected direct route (0 transfers, 3600s) in Pareto results") } } // TestFindRouteWith2Transfers tests route finding with exactly 2 transfers. func TestFindRouteWith2Transfers(t *testing.T) { graph := NewGraphWithoutYandex() // Add stations: A -> B -> C -> D (3 hops, 2 transfers) graph.AddNode(&Node{ID: "a", Type: NodeTypeStation, Name: "A", CityCode: "c1"}) graph.AddNode(&Node{ID: "b", Type: NodeTypeStation, Name: "B", CityCode: "c1"}) graph.AddNode(&Node{ID: "c", Type: NodeTypeStation, Name: "C", CityCode: "c1"}) graph.AddNode(&Node{ID: "d", Type: NodeTypeStation, Name: "D", CityCode: "c1"}) // Real edges between consecutive stations 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}) // 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 := NewGraphWithoutYandex() 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 := NewGraphWithoutYandex() 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 for _, n := range nodes { if n.Type == NodeTypeCity { cityCount++ } } 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") } } // 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") } } // TestSelectHubStations tests hub station selection by outgoing flights. func TestSelectHubStations(t *testing.T) { stations := []StationInfo{ {ID: "s1", Name: "Moscow", CityCode: "m1", CityName: "Moscow"}, {ID: "s2", Name: "SmallTown", CityCode: "s1", CityName: "Townville"}, {ID: "s3", Name: "CapitalCity", CityCode: "c1", CityName: "Capital"}, } // With minOutgoingFlights=1, stations with default outgoing flights are hubs // defaultOutgoingFlights is set to 1 so stations get selected criteria := hubCriteria{minOutgoingFlights: 1, defaultOutgoingFlights: 1} result := SelectHubStations(stations, criteria) // Moscow has default outgoing flights and should be a hub moscowFound := false for _, hub := range result.Hubs { if hub.Station.Name == "Moscow" { moscowFound = true if !hub.IsHub { t.Error("Moscow should be selected as a hub with minOutgoingFlights=1") } break } } if !moscowFound { t.Error("expected Moscow to be in hub selection results") } // With high minOutgoingFlights, all stations should be rejected highCriteria := hubCriteria{minOutgoingFlights: 100} highResult := SelectHubStations(stations, highCriteria) // All stations should be rejected when threshold is too high allRejected := true for _, hub := range highResult.Hubs { if hub.IsHub { allRejected = false break } } if !allRejected { t.Error("expected all stations to be rejected with minOutgoingFlights=100") } // Verify all rejected stations have IsHub=false for _, hub := range highResult.Rejected { if hub.IsHub { t.Error("rejected station should have IsHub=false") } } } // TestBuildGraphFromHubs tests graph building from hub stations. func TestBuildGraphFromHubs(t *testing.T) { stations := []StationInfo{ {ID: "s1", Name: "Moscow", CityCode: "m1", CityName: "Moscow"}, {ID: "s2", Name: "Tula", CityCode: "m1", CityName: "Tula"}, {ID: "s3", Name: "Simferopol", CityCode: "c1", CityName: "Simferopol"}, {ID: "s4", Name: "SmallCity", CityCode: "s1", CityName: "Smallville"}, } criteria := hubCriteria{minOutgoingFlights: 10, defaultOutgoingFlights: 10} graph := BuildGraphFromHubs(stations, criteria) // Should have station nodes + city nodes nodes := graph.Nodes() if len(nodes) < 3 { t.Errorf("expected at least 3 nodes (stations + cities), got %d", len(nodes)) } // Should have edges edges := graph.Edges() if len(edges) < 2 { t.Errorf("expected at least 2 edges, 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:m1"] { t.Error("expected city:m1 node") } if !cityIDs["city:c1"] { t.Error("expected city:c1 node") } // Verify hub stations are connected to city hubs // Find edges from Moscow to city hub moscowEdges := 0 for _, e := range edges { if e.From != nil && e.From.Name == "Moscow" { moscowEdges++ } } if moscowEdges == 0 { t.Error("expected edges from Moscow to city hub") } } // TestExpandGraphLazy tests the lazy graph expansion method. func TestExpandGraphLazy(t *testing.T) { graph := NewGraphWithoutYandex() // Add a station node moscow := &Node{ ID: "s1", Type: NodeTypeStation, Name: "Moscow", } graph.AddNode(moscow) // Test expanding from a station to destination city opts := SearchOptions{FarTerm: false} err := graph.ExpandGraphLazy(moscow, "Simferopol", "2026-08-20", &opts) if err != nil { t.Errorf("expected no error from ExpandGraphLazy, got: %v", err) } // Should have added edges from Moscow to Simferopol city hub nodes := graph.Nodes() if len(nodes) < 2 { t.Errorf("expected at least 2 nodes (Moscow + Simferopol city), got %d", len(nodes)) } edges := graph.Edges() if len(edges) < 2 { t.Errorf("expected at least 2 edges (forward and reverse), got %d", len(edges)) } // Verify the edge exists moscowToSimferopol := false simferopolToMoscow := false for _, e := range edges { if e.From != nil && e.From.Name == "Moscow" && e.To != nil && e.To.Name == "Simferopol" { moscowToSimferopol = true } if e.From != nil && e.From.Name == "Simferopol" && e.To != nil && e.To.Name == "Moscow" { simferopolToMoscow = true } } if !moscowToSimferopol { t.Error("expected edge from Moscow to Simferopol") } if !simferopolToMoscow { t.Error("expected edge from Simferopol to Moscow") } } // TestExpandGraphLazy_FromCityHub tests expansion from a city hub. func TestExpandGraphLazy_FromCityHub(t *testing.T) { graph := NewGraphWithoutYandex() // Add a city hub node simferopol := &Node{ ID: "city:c1", Type: NodeTypeCity, Name: "Simferopol", } graph.AddNode(simferopol) // Test expanding from a city hub to station hubs opts := SearchOptions{FarTerm: false} err := graph.ExpandGraphLazy(simferopol, "Moscow", "2026-08-20", &opts) if err != nil { t.Errorf("expected no error from ExpandGraphLazy, got: %v", err) } // Should have added edges from Simferopol city to station hubs edges := graph.Edges() if len(edges) == 0 { t.Error("expected edges from city hub to station hubs") } } // TestExpandGraphLazy_InvalidNodeType tests invalid node type handling. func TestExpandGraphLazy_InvalidNodeType(t *testing.T) { graph := NewGraphWithoutYandex() // This test verifies the default case in ExpandGraphLazy // We can't easily create an invalid node type, so we just verify // the method handles errors gracefully opts := SearchOptions{FarTerm: false} err := graph.ExpandGraphLazy(nil, "Test", "2026-08-20", &opts) // Should not panic, just return an error if err == nil { t.Error("expected error from ExpandGraphLazy with nil node") } } // TestFindRouteWithLazyExpansion_0Transfers tests route finding with 0 transfers using lazy expansion. func TestFindRouteWithLazyExpansion_0Transfers(t *testing.T) { graph := NewGraphWithoutYandex() // Add stations: A -> B direct route (0 transfers) graph.AddNode(&Node{ID: "a", Type: NodeTypeStation, Name: "A", CityCode: "c1"}) graph.AddNode(&Node{ID: "b", Type: NodeTypeStation, Name: "B", CityCode: "c1"}) // Add real direct edge graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false}) // Search with max 0 transfers and lazy expansion enabled opts := SearchOptions{MaxTransfers: 0, MCT: 0, DestCityCode: "c1", Date: "2026-08-20"} result := graph.FindRoute("a", "b", opts) if result == nil { t.Error("expected route with 0 transfers using lazy expansion") } if result.TotalTransfers != 0 { t.Errorf("expected 0 transfers, got %d", result.TotalTransfers) } } // TestFindRouteWithLazyExpansion_1Transfer tests route finding with 1 transfer using lazy expansion. func TestFindRouteWithLazyExpansion_1Transfer(t *testing.T) { graph := NewGraphWithoutYandex() // Add stations: A -> C -> B (1 transfer via city hub) graph.AddNode(&Node{ID: "a", Type: NodeTypeStation, Name: "A", CityCode: "c1"}) graph.AddNode(&Node{ID: "c", Type: NodeTypeCity, Name: "CityHub", CityCode: "c1"}) graph.AddNode(&Node{ID: "b", Type: NodeTypeStation, Name: "B", CityCode: "c1"}) // Add real edges: A -> CityHub and CityHub -> B 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}) // Search with max 1 transfer and lazy expansion enabled opts := SearchOptions{MaxTransfers: 1, MCT: 0, DestCityCode: "c1", Date: "2026-08-20"} result := graph.FindRoute("a", "b", opts) if result == nil { t.Error("expected route with 1 transfer using lazy expansion") } if result.TotalTransfers > 1 { t.Errorf("expected at most 1 transfer, got %d", result.TotalTransfers) } } // TestFindRouteWithLazyExpansion_2Transfers tests route finding with 2 transfers using lazy expansion. func TestFindRouteWithLazyExpansion_2Transfers(t *testing.T) { graph := NewGraphWithoutYandex() // Add stations: A -> D -> E -> B (2 transfers) graph.AddNode(&Node{ID: "a", Type: NodeTypeStation, Name: "A", CityCode: "c1"}) graph.AddNode(&Node{ID: "d", Type: NodeTypeStation, Name: "D", CityCode: "c1"}) graph.AddNode(&Node{ID: "e", Type: NodeTypeStation, Name: "E", CityCode: "c1"}) graph.AddNode(&Node{ID: "b", Type: NodeTypeStation, Name: "B", CityCode: "c1"}) // Add real edges between consecutive stations 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}) // Search with max 2 transfers and lazy expansion enabled opts := SearchOptions{MaxTransfers: 2, MCT: 0, DestCityCode: "c1", Date: "2026-08-20"} result := graph.FindRoute("a", "b", opts) if result == nil { t.Error("expected route with 2 transfers using lazy expansion") } if result.TotalTransfers != 0 { t.Errorf("expected 0 transfers (all real edges), got %d", result.TotalTransfers) } } // TestFindRoute_LazyExpansion_TransferLimitEnforcement tests that transfer limit is enforced during lazy expansion. func TestFindRoute_LazyExpansion_TransferLimitEnforcement(t *testing.T) { graph := NewGraphWithoutYandex() // Add a chain of stations that would require 4 transfers (exceeds limit of 3) 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: NodeTypeCity, Name: "City3", CityCode: "c1"}) graph.AddNode(&Node{ID: "s5", Type: NodeTypeCity, Name: "City4", CityCode: "c1"}) graph.AddNode(&Node{ID: "s6", 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}) graph.AddEdge(&Edge{From: graph.Nodes()[4], To: graph.Nodes()[5], Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true}) // Search with max 3 transfers - should not find route requiring 5 transfers opts := SearchOptions{MaxTransfers: 3, MCT: 0, DestCityCode: "c1", Date: "2026-08-20"} result := graph.FindRoute("s1", "s6", opts) if result != nil { t.Error("expected nil route when transfers exceed limit during lazy expansion") } } // TestFindRouteWithLazyExpansion_MCTCalculation tests MCT calculation during lazy expansion. func TestFindRouteWithLazyExpansion_MCTCalculation(t *testing.T) { graph := NewGraphWithoutYandex() // 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}) // Search with MCT and lazy expansion opts := SearchOptions{MaxTransfers: 2, MCT: 300, DestCityCode: "c1", Date: "2026-08-20"} result := graph.FindRoute("s1", "s3", opts) if result == nil { t.Error("expected route with MCT calculation") } // MCT of 300s (5 min) is applied at each transfer point during BFS // With 1 transfer (s1 -> city_hub -> s3), total duration includes MCT addition if result.TotalDuration < 3600 { t.Errorf("expected total duration at least 3600 (one real edge + MCT), got %d", result.TotalDuration) } // Should have exactly 1 transfer (city hub transfer, not counted as extra since edges are real) if result.TotalTransfers > 1 { t.Errorf("expected at most 1 transfer, got %d", result.TotalTransfers) } } // TestBuildGraphFromHubs_EdgeCases tests edge cases for hub graph building. func TestBuildGraphFromHubs_EdgeCases(t *testing.T) { // Empty stations list graph := BuildGraphFromHubs(nil, hubCriteria{minOutgoingFlights: 10, defaultOutgoingFlights: 10}) 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 = BuildGraphFromHubs([]StationInfo{{ID: "s1", Name: "Only", CityCode: "c1", CityName: "City1"}}, hubCriteria{minOutgoingFlights: 10, defaultOutgoingFlights: 10}) 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 = BuildGraphFromHubs([]StationInfo{ {ID: "s1", Name: "Station 1", CityCode: "c1", CityName: "City1"}, {ID: "s2", Name: "Station 2", CityCode: "c1", CityName: "City1"}, }, hubCriteria{minOutgoingFlights: 10, defaultOutgoingFlights: 10}) nodes := graph.Nodes() cityCount := 0 for _, n := range nodes { if n.Type == NodeTypeCity { cityCount++ } } if cityCount != 1 { t.Errorf("expected 1 city node for duplicate city codes, got %d", cityCount) } } // TestSearchRoutes_onDemand tests the Yandex client's SearchRoutes method // for on-demand route searching between station pairs. func TestSearchRoutes_onDemand(t *testing.T) { c := yandex.NewClient("test-key") yandex.ResetCircuitBreaker(c) resp, err := c.SearchRoutes(context.Background(), "s9600213", "s9600396", "2026-08-15") if err != nil { t.Skipf("skipping SearchRoutes test: %v (circuit breaker may be open)", err) } // Verify response structure if resp == nil { t.Error("expected non-nil response from SearchRoutes") } if resp.Pagination.Total < 0 { t.Error("expected valid pagination total from SearchRoutes") } } func TestLazySearchCacheIntegration(t *testing.T) { // This test verifies the cache key generation and TTL policies // work correctly with the lazy expansion strategy // Test cache key generation searchKey := cache.GetSearchKey("s9600213", "city:c213", "2026-08-15") // Verify the cache key kind is "search" if searchKey.Kind != "search" { t.Errorf("expected search key kind to be 'search', got '%v'", searchKey.Kind) } // Verify the From field if searchKey.From != "s9600213" { t.Errorf("expected From to be 's9600213', got '%v'", searchKey.From) } // Verify the To field if searchKey.To != "city:c213" { t.Errorf("expected To to be 'city:c213', got '%v'", searchKey.To) } // Verify the Date field if searchKey.Date != "2026-08-15" { t.Errorf("expected Date to be '2026-08-15', got '%v'", searchKey.Date) } // Test far-term TTL key farTermKey := cache.GetSearchKey("s9600213", "city:c213", "2026-08-20") if farTermKey.Kind != "search" { t.Errorf("expected far-term search key kind to be 'search', got '%v'", farTermKey.Kind) } if farTermKey.To != "city:c213" { t.Errorf("expected far-term To to be 'city:c213', got '%v'", farTermKey.To) } if farTermKey.Date != "2026-08-20" { t.Errorf("expected far-term Date to be '2026-08-20', got '%v'", farTermKey.Date) } }