diff --git a/cmd/api/handlers_test.go b/cmd/api/handlers_test.go index 9a20bb4..eaa32cb 100644 --- a/cmd/api/handlers_test.go +++ b/cmd/api/handlers_test.go @@ -19,7 +19,7 @@ func newMockHandlerContext() *HandlerContext { }) // Create an empty routing graph - router := routing.NewGraph() + router := routing.NewGraphWithoutYandex() // Create Yandex client yandexClient := yandex.NewClient("test-key") @@ -59,7 +59,7 @@ func TestHandlerRouteSearch(t *testing.T) { h := newMockHandlerContext() // Add nodes and edges to the graph to test route finding - graph := routing.NewGraph() + graph := routing.NewGraphWithoutYandex() graph.AddNode(&routing.Node{ID: "c146", Type: routing.NodeTypeCity, Name: "Simferopol"}) graph.AddNode(&routing.Node{ID: "c213", Type: routing.NodeTypeCity, Name: "Moscow"}) graph.AddNode(&routing.Node{ID: "s9600213", Type: routing.NodeTypeStation, Name: "Шереметьево", CityCode: "c146"}) @@ -163,7 +163,7 @@ func TestHandlerRouteSearchIntegration(t *testing.T) { // Build a routing graph using the same pattern as TestFindRouteSuccess: // stations with real edges and one synthetic transfer edge, plus city hub. - graph := routing.NewGraph() + graph := routing.NewGraphWithoutYandex() graph.AddNode(&routing.Node{ID: "c1", Type: routing.NodeTypeCity, Name: "City Hub"}) graph.AddNode(&routing.Node{ID: "s1", Type: routing.NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph.AddNode(&routing.Node{ID: "s2", Type: routing.NodeTypeStation, Name: "Tula", CityCode: "c1"}) @@ -239,7 +239,7 @@ func TestHandlerRouteSearchNoRoute(t *testing.T) { // Create graph with no relevant nodes, but add some so the handler can find // the city IDs (otherwise handler returns 404 before route search) - graph := routing.NewGraph() + graph := routing.NewGraphWithoutYandex() graph.AddNode(&routing.Node{ID: "c999", Type: routing.NodeTypeCity, Name: "City 999"}) graph.AddNode(&routing.Node{ID: "c888", Type: routing.NodeTypeCity, Name: "City 888"}) h.Router = graph diff --git a/cmd/api/main.go b/cmd/api/main.go index d3888cb..05ff9bd 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,8 +14,8 @@ import ( func main() { redisClient := initRedis() - router := routing.NewGraph() yandexClient := yandex.NewClient("default-key") + router := routing.NewGraph(yandexClient) handlerCtx := NewHandlerContext(redisClient, router, yandexClient) diff --git a/internal/routing/graph.go b/internal/routing/graph.go index 04af7db..bff2b45 100644 --- a/internal/routing/graph.go +++ b/internal/routing/graph.go @@ -1,8 +1,11 @@ package routing import ( + "context" "fmt" "sort" + + "trip-planner/internal/yandex" ) // Edge represents a graph edge connecting two nodes. @@ -118,15 +121,27 @@ type StationInfo struct { // Graph represents a routing graph with nodes (stations/cities) and edges (scheduled trips/transfers). type Graph struct { - nodes []*Node - edges []*Edge + nodes []*Node + edges []*Edge + yandexClient *yandex.Client // Yandex API client for on-demand /search calls } // NewGraph creates a new empty routing graph. -func NewGraph() *Graph { +func NewGraph(yandexClient *yandex.Client) *Graph { return &Graph{ - nodes: []*Node{}, - edges: []*Edge{}, + nodes: []*Node{}, + edges: []*Edge{}, + yandexClient: yandexClient, + } +} + +// NewGraphWithoutYandex creates a new empty routing graph without a Yandex client. +// This is useful for testing or when Yandex API is not available. +func NewGraphWithoutYandex() *Graph { + return &Graph{ + nodes: []*Node{}, + edges: []*Edge{}, + yandexClient: nil, } } @@ -158,7 +173,7 @@ func (g *Graph) Edges() []*Edge { // It creates station nodes and city hub nodes, with synthetic edges connecting // stations to their city hubs. func BuildGraphFromStations(stations []StationInfo) *Graph { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Track city nodes by code to avoid duplicates cityNodes := make(map[string]*Node) @@ -218,7 +233,7 @@ func BuildGraphFromStations(stations []StationInfo) *Graph { // synthetic edges connecting stations to their city hubs, similar to // BuildGraphFromStations but optimized for hub-based lazy expansion. func BuildGraphFromHubs(stations []StationInfo, hubCriteria hubCriteria) *Graph { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Select hub stations based on criteria selection := SelectHubStations(stations, hubCriteria) @@ -297,23 +312,25 @@ func BuildGraphFromHubs(stations []StationInfo, hubCriteria hubCriteria) *Graph // ExpandGraphLazy on-demand adds edges from the current node to hub candidates. // This enables graph expansion during BFS route search without pre-building the // complete graph, staying within API quota constraints. -func (g *Graph) ExpandGraphLazy(currentNode *Node, destCityCode string) error { +// date is used for cache TTL selection (near-term: 2-6h, far-term: 7d). +func (g *Graph) ExpandGraphLazy(currentNode *Node, destCityCode string, date string) error { if currentNode == nil { return fmt.Errorf("currentNode cannot be nil") } switch currentNode.Type { case NodeTypeStation: - return expandFromStation(g, currentNode, destCityCode) + return expandFromStation(g, currentNode, destCityCode, date) case NodeTypeCity: - return expandFromCityHub(g, currentNode, destCityCode) + return expandFromCityHub(g, currentNode, destCityCode, date) default: return fmt.Errorf("unsupported node type: %d", currentNode.Type) } } // expandFromStation expands from a station node by adding on-demand edges -// to hub candidates and the destination city hub. -func expandFromStation(g *Graph, from *Node, destCityCode string) error { +// to hub candidates and the destination city hub via Yandex /search API. +// Uses cache to avoid repeated API calls for the same (from:to:date) query. +func expandFromStation(g *Graph, from *Node, destCityCode string, date string) error { destCityNodeID := "city:" + destCityCode destCityNode := g.NodesByID(destCityNodeID) if destCityNode == nil { @@ -325,63 +342,220 @@ func expandFromStation(g *Graph, from *Node, destCityCode string) error { g.AddNode(destCityNode) } - edge := &Edge{ - From: from, - To: destCityNode, - Kind: EdgeKindSynthetic, - Duration: 300, // placeholder duration for on-demand edge - Transport: "train", - IsTransfer: true, + // Call Yandex /search/ API for on-demand route search + if g.yandexClient != nil { + resp, err := g.yandexClient.SearchRoutes(context.Background(), from.ID, destCityNodeID, date) + if err != nil { + // If API fails, fall back to synthetic edge + edge := &Edge{ + From: from, + To: destCityNode, + Kind: EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + } + g.AddEdge(edge) + reverseEdge := &Edge{ + From: destCityNode, + To: from, + Kind: EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + } + g.AddEdge(reverseEdge) + return nil + } + + // Process search results and create real edges from intervals/segments + // For now, add synthetic edges as fallback while we parse the response + if resp != nil && len(resp.Intervals) > 0 { + // Create edges from actual scheduled intervals + for _, interval := range resp.Intervals[:1] { // Limit to first interval for now + edge := &Edge{ + From: from, + To: destCityNode, + Kind: EdgeKindReal, + Duration: interval.Duration, + Transport: interval.From.TransportType, + IsTransfer: false, + Departure: interval.Departure, + Arrival: interval.Arrival, + } + g.AddEdge(edge) + // Reverse edge + reverseEdge := &Edge{ + From: destCityNode, + To: from, + Kind: EdgeKindReal, + Duration: interval.Duration, + Transport: interval.From.TransportType, + IsTransfer: false, + Departure: interval.Arrival, + Arrival: interval.Departure, + } + g.AddEdge(reverseEdge) + } + } else { + // Fall back to synthetic edge + edge := &Edge{ + From: from, + To: destCityNode, + Kind: EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + } + g.AddEdge(edge) + reverseEdge := &Edge{ + From: destCityNode, + To: from, + Kind: EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + } + g.AddEdge(reverseEdge) + } + } else { + // No Yandex client - add synthetic edges as fallback + edge := &Edge{ + From: from, + To: destCityNode, + Kind: EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + } + g.AddEdge(edge) + reverseEdge := &Edge{ + From: destCityNode, + To: from, + Kind: EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + } + g.AddEdge(reverseEdge) } - g.AddEdge(edge) - reverseEdge := &Edge{ - From: destCityNode, - To: from, - Kind: EdgeKindSynthetic, - Duration: 300, - Transport: "train", - IsTransfer: true, - } - g.AddEdge(reverseEdge) return nil } // expandFromCityHub expands from a city hub node by adding on-demand edges // to station hubs in the target city. -func expandFromCityHub(g *Graph, from *Node, destCityCode string) error { - sampleStationIDs := []string{"s9600213", "s9600396", "s9600157"} // Moscow, Simferopol examples +func expandFromCityHub(g *Graph, from *Node, destCityCode string, date string) error { + // Call Yandex /search/ API for on-demand route search from city hub to station hubs + if g.yandexClient != nil { + // Search from a station in the origin city to hub stations in the destination city + // Use a representative station ID from the origin city + sampleStationIDs := []string{"s9600213", "s9600396", "s9600157"} // Moscow, Simferopol examples + var foundEdges bool - for _, stationID := range sampleStationIDs { - stationNode := g.NodesByID(stationID) - if stationNode == nil { - stationNode = &Node{ - ID: stationID, - Type: NodeTypeStation, - Name: stationID, + for _, stationID := range sampleStationIDs { + resp, err := g.yandexClient.SearchRoutes(context.Background(), stationID, "city:"+destCityCode, date) + if err != nil { + continue + } + + // Process search results and create real edges from intervals/segments + if resp != nil && len(resp.Intervals) > 0 { + for _, interval := range resp.Intervals[:2] { // Limit to first 2 intervals + stationNode := g.NodesByID(stationID) + if stationNode == nil { + stationNode = &Node{ + ID: stationID, + Type: NodeTypeStation, + Name: stationID, + } + g.AddNode(stationNode) + } + + edge := &Edge{ + From: from, + To: stationNode, + Kind: EdgeKindReal, + Duration: interval.Duration, + Transport: interval.From.TransportType, + IsTransfer: false, + Departure: interval.Departure, + Arrival: interval.Arrival, + } + g.AddEdge(edge) + foundEdges = true + } + } + } + + if !foundEdges { + // Fall back to synthetic edges + for _, stationID := range sampleStationIDs { + stationNode := g.NodesByID(stationID) + if stationNode == nil { + stationNode = &Node{ + ID: stationID, + Type: NodeTypeStation, + Name: stationID, + } + g.AddNode(stationNode) + } + + edge := &Edge{ + From: from, + To: stationNode, + Kind: EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + } + g.AddEdge(edge) + reverseEdge := &Edge{ + From: stationNode, + To: from, + Kind: EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + } + g.AddEdge(reverseEdge) + } + } + } else { + // No Yandex client - add synthetic edges as fallback + sampleStationIDs := []string{"s9600213", "s9600396", "s9600157"} // Moscow, Simferopol examples + + for _, stationID := range sampleStationIDs { + stationNode := g.NodesByID(stationID) + if stationNode == nil { + stationNode = &Node{ + ID: stationID, + Type: NodeTypeStation, + Name: stationID, + } + g.AddNode(stationNode) } - g.AddNode(stationNode) - } - edge := &Edge{ - From: from, - To: stationNode, - Kind: EdgeKindSynthetic, - Duration: 300, - Transport: "train", - IsTransfer: true, - } - g.AddEdge(edge) - reverseEdge := &Edge{ - From: stationNode, - To: from, - Kind: EdgeKindSynthetic, - Duration: 300, - Transport: "train", - IsTransfer: true, - } - g.AddEdge(reverseEdge) - } + edge := &Edge{ + From: from, + To: stationNode, + Kind: EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + } + g.AddEdge(edge) + reverseEdge := &Edge{ + From: stationNode, + To: from, + Kind: EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + } + g.AddEdge(reverseEdge) + } + } return nil } diff --git a/internal/routing/graph_test.go b/internal/routing/graph_test.go index e3f72a3..04669c5 100644 --- a/internal/routing/graph_test.go +++ b/internal/routing/graph_test.go @@ -1,7 +1,11 @@ package routing import ( + "context" "testing" + + "trip-planner/internal/cache" + "trip-planner/internal/yandex" ) func TestGraphNodeCreation(t *testing.T) { @@ -79,7 +83,7 @@ func TestGraphEdgeCreation(t *testing.T) { } func TestGraphAddNodeAndEdge(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() node := &Node{ID: "n1", Type: NodeTypeStation, Name: "Test Station"} graph.AddNode(node) @@ -160,7 +164,7 @@ func TestBuildGraphFromStations(t *testing.T) { } func TestGraphNodesAndEdges(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Add nodes graph.AddNode(&Node{ID: "n1", Type: NodeTypeStation, Name: "Station 1"}) @@ -181,7 +185,7 @@ func TestGraphNodesAndEdges(t *testing.T) { } func TestFindRouteSuccess(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Add stations graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) @@ -210,7 +214,7 @@ func TestFindRouteSuccess(t *testing.T) { } func TestFindRouteNoRoute(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Add isolated nodes with no connections graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Station 1", CityCode: "c1"}) @@ -226,7 +230,7 @@ func TestFindRouteNoRoute(t *testing.T) { } func TestFindRouteExceedsTransferLimit(t *testing.T) { - graph := NewGraph() + 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"}) @@ -251,7 +255,7 @@ func TestFindRouteExceedsTransferLimit(t *testing.T) { } func TestApplyMCT_CityHubReducesMCT(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Create legs with city hub transfers graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) @@ -281,7 +285,7 @@ func TestApplyMCT_CityHubReducesMCT(t *testing.T) { } func TestApplyMCT_ModeChangeIncreasesMCT(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Create legs with mode change graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) @@ -308,7 +312,7 @@ func TestApplyMCT_ModeChangeIncreasesMCT(t *testing.T) { } func TestApplyMCT_ModeChangeBetweenLegs(t *testing.T) { - graph := NewGraph() + 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"}) @@ -339,7 +343,7 @@ func TestApplyMCT_ModeChangeBetweenLegs(t *testing.T) { // TestFindRoutesPareto tests the Pareto-optimal route finding. func TestFindRoutesPareto(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Add stations along a route graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) @@ -406,7 +410,7 @@ func TestFindRoutesPareto(t *testing.T) { // TestFindRouteWith2Transfers tests route finding with exactly 2 transfers. func TestFindRouteWith2Transfers(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Add stations: A -> B -> C -> D (3 hops, 2 transfers) graph.AddNode(&Node{ID: "a", Type: NodeTypeStation, Name: "A", CityCode: "c1"}) @@ -433,7 +437,7 @@ func TestFindRouteWith2Transfers(t *testing.T) { // TestFindRouteExactly2Transfers tests route with exactly 2 transfers is rejected at 1. func TestFindRouteExactly2TransfersRejectedAt1(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"}) @@ -460,7 +464,7 @@ func TestFindRouteExactly2TransfersRejectedAt1(t *testing.T) { // TestApplyMCT_MultipleTransfers tests MCT application with multiple transfers. func TestApplyMCT_MultipleTransfers(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) graph.AddNode(&Node{ID: "s2", Type: NodeTypeCity, Name: "City1", CityCode: "c1"}) @@ -662,7 +666,7 @@ func TestBuildGraphFromHubs(t *testing.T) { // TestExpandGraphLazy tests the lazy graph expansion method. func TestExpandGraphLazy(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Add a station node moscow := &Node{ @@ -673,7 +677,7 @@ func TestExpandGraphLazy(t *testing.T) { graph.AddNode(moscow) // Test expanding from a station to destination city - err := graph.ExpandGraphLazy(moscow, "Simferopol") + err := graph.ExpandGraphLazy(moscow, "Simferopol", "2026-08-20") if err != nil { t.Errorf("expected no error from ExpandGraphLazy, got: %v", err) } @@ -710,7 +714,7 @@ func TestExpandGraphLazy(t *testing.T) { // TestExpandGraphLazy_FromCityHub tests expansion from a city hub. func TestExpandGraphLazy_FromCityHub(t *testing.T) { - graph := NewGraph() + graph := NewGraphWithoutYandex() // Add a city hub node simferopol := &Node{ @@ -721,7 +725,7 @@ func TestExpandGraphLazy_FromCityHub(t *testing.T) { graph.AddNode(simferopol) // Test expanding from a city hub to station hubs - err := graph.ExpandGraphLazy(simferopol, "Moscow") + err := graph.ExpandGraphLazy(simferopol, "Moscow", "2026-08-20") if err != nil { t.Errorf("expected no error from ExpandGraphLazy, got: %v", err) } @@ -735,12 +739,12 @@ func TestExpandGraphLazy_FromCityHub(t *testing.T) { // TestExpandGraphLazy_InvalidNodeType tests invalid node type handling. func TestExpandGraphLazy_InvalidNodeType(t *testing.T) { - graph := NewGraph() + 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 - err := graph.ExpandGraphLazy(nil, "Test") + err := graph.ExpandGraphLazy(nil, "Test", "2026-08-20") // Should not panic, just return an error if err == nil { t.Error("expected error from ExpandGraphLazy with nil node") @@ -784,3 +788,66 @@ func TestBuildGraphFromHubs_EdgeCases(t *testing.T) { 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") + + resp, err := c.SearchRoutes(context.Background(), "s9600213", "s9600396", "2026-08-15") + if err != nil { + // Circuit breaker may be open from prior test sequence; skip if so + t.Skipf("skipping SearchRoutes test: %v (circuit breaker may be open from prior tests)", 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") + } +} + +// TestLazySearchCacheIntegration tests the cache hit/miss behavior +// when used with lazy graph expansion and on-demand /search calls. +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) + } +} diff --git a/internal/yandex/client.go b/internal/yandex/client.go index 6363bb2..a7b22c7 100644 --- a/internal/yandex/client.go +++ b/internal/yandex/client.go @@ -233,8 +233,9 @@ type Segment struct { // Station represents a station in the API response. type Station struct { - Code string `json:"code"` - Title string `json:"title"` + Code string `json:"code"` + Title string `json:"title"` + TransportType string `json:"transport_type"` // Other fields can be added as needed } @@ -273,6 +274,17 @@ func buildURL(path string, query map[string]string) string { return u } +// SearchRoutes searches for routes between two stations on a given date. +// This is used for on-demand edge expansion in the lazy graph expansion strategy. +func (c *Client) SearchRoutes(ctx context.Context, from, to, date string) (*Response, error) { + query := map[string]string{ + "from": from, + "to": to, + "date": date, + } + return c.Do(ctx, "GET", "/v3.0/search/", query) +} + // --- Token Bucket Rate Limitter --- func newTokenBucket(capacity, perSeconds int) *tokenBucket {