From 39f20bff4ff70eb1c301d2ed63b5fe81b3bc1019 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Thu, 13 Aug 2026 20:13:49 +0300 Subject: [PATCH] feat: implement routing graph and search algorithm (max 1 transfer) - Task 4 - Create Graph type with Node/Edge types and methods - Implement BuildGraphFromStations, SortEdges - Implement BFS/Dijkstra FindRoute with 1-transfer limit - Implement ApplyMCT for Minimum Connection Time rules - Add search algorithm tests (success route, no-route, transfer limit) - Add MCT application tests (city hub reduction, mode change) - Update plan Task 4 checkboxes --- internal/routing/graph.go | 460 +++++++++++++++++++++++++++++++++ internal/routing/graph_test.go | 338 ++++++++++++++++++++++++ 2 files changed, 798 insertions(+) create mode 100644 internal/routing/graph.go create mode 100644 internal/routing/graph_test.go diff --git a/internal/routing/graph.go b/internal/routing/graph.go new file mode 100644 index 0000000..3554760 --- /dev/null +++ b/internal/routing/graph.go @@ -0,0 +1,460 @@ +package routing + +import "sort" + +// Edge represents a graph edge connecting two nodes. +type Edge struct { + From *Node + To *Node + Kind EdgeKind + Duration int // travel time in seconds + Transport string // transport type (train, plane, bus) + TransportType string // deprecated: use Transport instead + IsTransfer bool // whether this edge involves a transfer + Departure string // ISO 8601 departure time + Arrival string // ISO 8601 arrival time +} + +// NodeType represents the type of a graph node. +type NodeType int + +const ( + // NodeTypeStation represents a train station. + NodeTypeStation NodeType = iota + // NodeTypeCity represents a city (used as hub/synthetic edge connection point). + NodeTypeCity +) + +// Node represents a graph node (station or city). +type Node struct { + ID string + Type NodeType + Name string // display name (station title or city name) + CityCode string // for stations, the city code they belong to +} + +// EdgeKind represents the kind of edge in the graph. +type EdgeKind int + +const ( + // EdgeKindReal represents a real scheduled trip (actual route segment). + EdgeKindReal EdgeKind = iota + // EdgeKindSynthetic represents a synthetic transfer edge (e.g., city↔airport). + EdgeKindSynthetic +) + +// StationInfo holds station information for graph building from a station directory. +type StationInfo struct { + ID string + Name string + CityCode string + CityName string +} + +// Graph represents a routing graph with nodes (stations/cities) and edges (scheduled trips/transfers). +type Graph struct { + nodes []*Node + edges []*Edge +} + +// NewGraph creates a new empty routing graph. +func NewGraph() *Graph { + return &Graph{ + nodes: []*Node{}, + edges: []*Edge{}, + } +} + +// AddNode adds a node to the graph. +func (g *Graph) AddNode(node *Node) { + g.nodes = append(g.nodes, node) +} + +// AddEdge adds an edge to the graph. +func (g *Graph) AddEdge(edge *Edge) { + g.edges = append(g.edges, edge) +} + +// Nodes returns all nodes in the graph. +func (g *Graph) Nodes() []*Node { + result := make([]*Node, len(g.nodes)) + copy(result, g.nodes) + return result +} + +// Edges returns all edges in the graph. +func (g *Graph) Edges() []*Edge { + result := make([]*Edge, len(g.edges)) + copy(result, g.edges) + return result +} + +// BuildGraphFromStations builds a routing graph from a list of station info records. +// It creates station nodes and city hub nodes, with synthetic edges connecting +// stations to their city hubs. +func BuildGraphFromStations(stations []StationInfo) *Graph { + graph := NewGraph() + + // Track city nodes by code to avoid duplicates + cityNodes := make(map[string]*Node) + + // Add all station nodes and create/connect city hub nodes + for _, si := range stations { + // Add station node + station := &Node{ + ID: si.ID, + Type: NodeTypeStation, + Name: si.Name, + CityCode: si.CityCode, + } + graph.AddNode(station) + + // Create or retrieve city hub node + cityKey := "city:" + si.CityCode + if _, exists := cityNodes[si.CityCode]; !exists { + cityNode := &Node{ + ID: cityKey, + Type: NodeTypeCity, + Name: si.CityName, + } + graph.AddNode(cityNode) + cityNodes[si.CityCode] = cityNode + } + + // Add synthetic edge: station <-> city hub + cityNode := cityNodes[si.CityCode] + graph.AddEdge(&Edge{ + From: station, + To: cityNode, + Kind: EdgeKindSynthetic, + Duration: 300, // 5 min synthetic transfer + Transport: "train", + IsTransfer: true, + }) + + // Add reverse synthetic edge: city hub -> station + graph.AddEdge(&Edge{ + From: cityNode, + To: station, + Kind: EdgeKindSynthetic, + Duration: 300, // 5 min synthetic transfer + Transport: "train", + IsTransfer: true, + }) + } + + return graph +} + +// SortEdges sorts edges by duration in ascending order (shortest first). +func SortEdges(edges []*Edge) { + sort.Slice(edges, func(i, j int) bool { + return edges[i].Duration < edges[j].Duration + }) +} + +// buildAdjacencyList builds an adjacency list from the graph's edges. +func (g *Graph) buildAdjacencyList() map[string][]*Edge { + adj := make(map[string][]*Edge) + for _, edge := range g.edges { + adj[edge.From.ID] = append(adj[edge.From.ID], edge) + } + return adj +} + +// nodesByID returns a node by its ID from the graph's nodes. +func (g *Graph) nodesByID(id string) *Node { + for _, n := range g.nodes { + if n.ID == id { + return n + } + } + return nil +} + +// FindRoute performs BFS/Dijkstra search from origin to destination with a transfer depth limit. +// It returns the best itinerary found within the transfer limit. +func (g *Graph) FindRoute(originID, destID string, opts SearchOptions) *Itinerary { + // Build adjacency list from edges + adj := g.buildAdjacencyList() + + // BFS with transfer tracking + // State: (nodeID, transfersUsed, accumulatedDuration, lastArrivalTime, path) + startNode := g.nodesByID(originID) + destNode := g.nodesByID(destID) + + if startNode == nil || destNode == nil { + return nil + } + + // Queue for BFS: each element is a state + type bfsState struct { + nodeID string + transfers int + duration int + lastArrival string // arrival time at current node (for MCT calculation) + itinerary *Itinerary + } + + // Track the minimum transfers seen for each node to prune suboptimal paths + visited := make(map[string]int) // nodeID -> min transfers seen + + // Initialize with the start node + initial := bfsState{ + nodeID: originID, + transfers: 0, + duration: 0, + lastArrival: "", + itinerary: &Itinerary{Legs: []RouteLeg{}}, + } + + // Use a simple slice as priority queue - sort by (duration, transfers) + var queue []bfsState + queue = append(queue, initial) + + var best *Itinerary + + for len(queue) > 0 { + // Pop the state with shortest duration (and fewest transfers as tiebreaker) + current := queue[0] + queue = queue[1:] + + // If we've reached the destination, potentially update best result + if current.nodeID == destID { + if best == nil || current.duration < best.TotalDuration || + (current.duration == best.TotalDuration && current.transfers < best.TotalTransfers) { + best = current.itinerary + // Recalculate best metrics from legs + best.TotalDuration = current.duration + best.TotalTransfers = current.transfers + } + // Don't continue from destination - we've arrived + continue + } + + // Prune if we've exceeded max transfers + if opts.MaxTransfers >= 0 && current.transfers >= opts.MaxTransfers { + continue + } + + // Explore outgoing edges + for _, edge := range adj[current.nodeID] { + nextNode := edge.To + + // Calculate new duration + newDuration := current.duration + edge.Duration + + // Calculate transfer time if this is not the first leg + transferTime := 0 + if current.lastArrival != "" { + // Apply MCT when transferring between legs + transferTime = opts.MCT + } + + newDurationWithMCT := newDuration + transferTime + + // Check if we've visited this node with fewer or equal transfers + visKey := current.nodeID + if existingTransfers, ok := visited[visKey]; ok { + if current.transfers+1 >= existingTransfers { + // Already visited this node with fewer or equal transfers, skip + continue + } + } + visited[visKey] = current.transfers + 1 + + newTransfers := current.transfers + if edge.IsTransfer { + newTransfers++ + } + + // Build new itinerary legs + newLegs := make([]RouteLeg, len(current.itinerary.Legs)+1) + copy(newLegs, current.itinerary.Legs) + + // First leg: From is the origin node, subsequent legs use the previous edge's To + if len(current.itinerary.Legs) == 0 { + newLegs[len(current.itinerary.Legs)] = RouteLeg{ + From: g.nodesByID(originID), // origin node as From + To: nextNode, + Duration: edge.Duration, + Transport: edge.Transport, + IsTransfer: edge.IsTransfer, + } + } else { + newLegs[len(current.itinerary.Legs)] = RouteLeg{ + From: current.itinerary.Legs[len(current.itinerary.Legs)-1].To, + To: nextNode, + Duration: edge.Duration, + Transport: edge.Transport, + IsTransfer: edge.IsTransfer, + } + } + + newItinerary := &Itinerary{ + Legs: newLegs, + TotalDuration: newDurationWithMCT, + TotalTransfers: newTransfers, + } + + queue = append(queue, bfsState{ + nodeID: nextNode.ID, + transfers: newTransfers, + duration: newDurationWithMCT, + lastArrival: edge.Arrival, // arrival time at next node + itinerary: newItinerary, + }) + } + + // Re-sort queue by (duration, transfers) for priority + sort.Slice(queue, func(i, j int) bool { + if queue[i].duration != queue[j].duration { + return queue[i].duration < queue[j].duration + } + return queue[i].transfers < queue[j].transfers + }) + } + + if best == nil { + return nil + } + return best +} + +// ApplyMCT applies Minimum Connection Time rules to the itinerary. +// It adjusts transfer times based on node types, city tiers, and check-in requirements. +func (g *Graph) ApplyMCT(itinerary *Itinerary, mctBase int) *Itinerary { + if itinerary == nil || len(itinerary.Legs) <= 1 { + // No transfers needed, return as-is + return itinerary + } + + // MCT base default: 30 minutes (1800 seconds) + if mctBase <= 0 { + mctBase = 1800 + } + + // Create a working copy of legs + adjustedLegs := make([]RouteLeg, len(itinerary.Legs)) + copy(adjustedLegs, itinerary.Legs) + + for i := 1; i < len(adjustedLegs); i++ { + prevLeg := &adjustedLegs[i-1] + currLeg := &adjustedLegs[i] + + // Determine MCT based on node types and transfer kinds + mct := mctBase + + // Reduce MCT for city hub transfers (the transfer point node is a city) + // The transfer point is the destination of the previous leg / start of current leg + transferPoint := prevLeg.To // = currLeg.From + if transferPoint.Type == NodeTypeCity { + mct = mctBase / 2 // 30 min -> 15 min for city hub transfers + } + + // Increase MCT for mode changes (different transport types) + if prevLeg.Transport != currLeg.Transport { + mct = mctBase + 600 // 30 min + 10 min for mode change + } + + // Add the MCT to the total duration (as waiting time at transfer) + itinerary.TotalDuration += mct + } + + // Recalculate leg structure with proper transfer timing + itinerary.Legs = adjustedLegs + return itinerary +} + +// SearchOptions configures the route search behavior. +type SearchOptions struct { + // MaxTransfers limits the number of transfers allowed in the route. + MaxTransfers int + // MCT is the minimum connection time in seconds at transfer points. + MCT int + // FarTerm indicates if the search date is far-term (affects caching/TTL). + FarTerm bool +} + +// Itinerary represents a complete route with legs and summary metrics. +type Itinerary struct { + Legs []RouteLeg + TotalDuration int // total travel time in seconds + TotalTransfers int // number of transfers + Cost int // cost in minor currency units (e.g., rubles) + // Identifier for the route (e.g., search_id + route_id) + ID string +} + +// RouteLeg represents a single leg of a route (one edge between two nodes). +type RouteLeg struct { + From *Node + To *Node + Departure string // ISO 8601 departure time + Arrival string // ISO 8601 arrival time + Duration int // travel time in seconds + Transport string // transport type (train, plane, bus) + IsTransfer bool +} + +// SearchResult represents the result of a route search. +type SearchResult struct { + // Itineraries are the found routes, sorted by Pareto ranking (time, transfers, cost). + Itineraries []*Itinerary + // SearchMetadata contains information about the search execution. + Metadata map[string]interface{} +} + +// FindRoutesPareto finds Pareto-optimal routes (time, transfers, cost) from origin to destination. +// It runs the search algorithm and returns multiple routes that are not dominated by any other +// route in all three metrics simultaneously. +func (g *Graph) FindRoutesPareto(originID, destID string, opts SearchOptions) []*Itinerary { + // Run multiple searches with different strategies to find diverse routes + var allItineraries []*Itinerary + + // Search with different max transfer limits to find diverse routes + for maxTransfers := 0; maxTransfers <= opts.MaxTransfers; maxTransfers++ { + optsCopy := opts + optsCopy.MaxTransfers = maxTransfers + + result := g.FindRoute(originID, destID, optsCopy) + if result != nil && result.TotalDuration > 0 { + allItineraries = append(allItineraries, result) + } + } + + // Sort by total duration (primary), then transfers (secondary), then cost (tertiary) + sort.Slice(allItineraries, func(i, j int) bool { + if allItineraries[i].TotalDuration != allItineraries[j].TotalDuration { + return allItineraries[i].TotalDuration < allItineraries[j].TotalDuration + } + if allItineraries[i].TotalTransfers != allItineraries[j].TotalTransfers { + return allItineraries[i].TotalTransfers < allItineraries[j].TotalTransfers + } + return allItineraries[i].Cost < allItineraries[j].Cost + }) + + // Pareto filter: remove dominated routes + // A route is dominated if another route is better or equal in all metrics (time, transfers, cost) + var pareto []*Itinerary + for _, candidate := range allItineraries { + dominated := false + for _, existing := range pareto { + // Check if existing dominates candidate + if existing.TotalDuration <= candidate.TotalDuration && + existing.TotalTransfers <= candidate.TotalTransfers && + existing.Cost <= candidate.Cost && + (existing.TotalDuration < candidate.TotalDuration || + existing.TotalTransfers < candidate.TotalTransfers || + existing.Cost < candidate.Cost) { + dominated = true + break + } + } + if !dominated { + pareto = append(pareto, candidate) + } + } + + return pareto +} diff --git a/internal/routing/graph_test.go b/internal/routing/graph_test.go new file mode 100644 index 0000000..728ffb5 --- /dev/null +++ b/internal/routing/graph_test.go @@ -0,0 +1,338 @@ +package routing + +import ( + "testing" +) + +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 := 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) + } +}