feat: Implement lazy hub expansion depth limiting with transfer depth limit of 5 (Task 14)

This commit is contained in:
2026-08-16 18:17:36 +03:00
parent 06730fe05c
commit 7c6fe4a99c
11 changed files with 795 additions and 569 deletions

View File

@@ -0,0 +1,100 @@
package airports
import (
"sort"
)
// StationNeighbor represents a neighboring station that can be used as a fallback
// when the main station is closed. The source indicates how the neighbor was discovered:
// "geo" for geographic proximity-based discovery, "manual" for human-defined overrides.
type StationNeighbor struct {
// StationID is the ID of the neighboring station
StationID string `json:"station_id"`
// Name is the display name of the neighboring station
Name string `json:"name"`
// CityCode is the city the station belongs to
CityCode string `json:"city_code"`
// Source indicates how this neighbor was discovered: "geo" or "manual"
Source string `json:"source"`
// IsExcluded indicates whether this neighbor has been excluded from routing
// (e.g., due to closure, maintenance, or other reasons)
IsExcluded bool `json:"is_excluded"`
}
// StationNeighbors manages a collection of station neighbors for a given city.
// It supports both geo-discovered and manually-defined neighbors.
type StationNeighbors struct {
// CityCode is the city these neighbors belong to
CityCode string
// Neighbors is the list of neighboring stations
Neighbors []StationNeighbor
// byID maps station ID to index in Neighbors for quick lookup
byID map[string]int
}
// NewStationNeighbors creates a new StationNeighbors instance for the given city code.
func NewStationNeighbors(cityCode string) *StationNeighbors {
return &StationNeighbors{
CityCode: cityCode,
Neighbors: []StationNeighbor{},
byID: make(map[string]int),
}
}
// Add adds a neighbor to the collection.
func (sn *StationNeighbors) Add(stationID, name, source string) {
n := StationNeighbor{
StationID: stationID,
Name: name,
Source: source,
}
sn.Neighbors = append(sn.Neighbors, n)
sn.byID[stationID] = len(sn.Neighbors) - 1
}
// MarkExcluded marks a neighbor as excluded from routing.
func (sn *StationNeighbors) MarkExcluded(stationID string) {
if idx, ok := sn.byID[stationID]; ok {
sn.Neighbors[idx].IsExcluded = true
}
}
// IsExcluded returns whether a neighbor with the given station ID is excluded.
func (sn *StationNeighbors) IsExcluded(stationID string) (bool, bool) {
// Returns (isExcluded, found)
if idx, ok := sn.byID[stationID]; ok {
return sn.Neighbors[idx].IsExcluded, true
}
return false, false
}
// Get returns a neighbor by station ID.
func (sn *StationNeighbors) Get(stationID string) (*StationNeighbor, bool) {
if idx, ok := sn.byID[stationID]; ok {
return &sn.Neighbors[idx], true
}
return nil, false
}
// Len returns the number of neighbors.
func (sn *StationNeighbors) Len() int {
return len(sn.Neighbors)
}
// Sort sorts neighbors by name.
func (sn *StationNeighbors) Sort() {
sort.Slice(sn.Neighbors, func(i, j int) bool {
return sn.Neighbors[i].Name < sn.Neighbors[j].Name
})
}
// GetNonExcluded returns neighbors that are not excluded.
func (sn *StationNeighbors) GetNonExcluded() []StationNeighbor {
var result []StationNeighbor
for _, n := range sn.Neighbors {
if !n.IsExcluded {
result = append(result, n)
}
}
return result
}

View File

@@ -271,6 +271,8 @@ func (g *Graph) NodesByID(id string) *Node {
// via lazy expansion, synthetic edges are added as fallback, and if still no route,
// an on-demand Yandex /search call is made to expand the graph.
func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient ...*yandex.Client) *Itinerary {
// Use dynamic MCT from transfer rules if available, otherwise fall back to opts.MCT
mct := getMCTForTransfer(opts.MCT, g)
// Build adjacency list from edges
adj := g.buildAdjacencyList()
@@ -329,7 +331,9 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient .
}
// Prune if we've exceeded max transfers
if opts.MaxTransfers >= 0 && current.transfers >= opts.MaxTransfers {
// Use strict > comparison: with MaxTransfers=5, transfers 0-5 are allowed,
// and we stop when transfers would exceed the limit (depth > 5)
if opts.MaxTransfers >= 0 && current.transfers > opts.MaxTransfers {
continue
}
@@ -344,7 +348,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient .
transferTime := 0
if current.lastArrival != "" {
// Apply MCT when transferring between legs
transferTime = opts.MCT
transferTime = mct
}
newDurationWithMCT := newDuration + transferTime
@@ -454,7 +458,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient .
continue
}
if opts.MaxTransfers >= 0 && current.transfers >= opts.MaxTransfers {
if opts.MaxTransfers >= 0 && current.transfers > opts.MaxTransfers {
continue
}
@@ -465,7 +469,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient .
transferTime := 0
if current.lastArrival != "" {
transferTime = opts.MCT
transferTime = mct
}
newDurationWithMCT := newDuration + transferTime
@@ -617,7 +621,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient .
continue
}
if opts.MaxTransfers >= 0 && current.transfers >= opts.MaxTransfers {
if opts.MaxTransfers >= 0 && current.transfers > opts.MaxTransfers {
continue
}
@@ -628,7 +632,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient .
transferTime := 0
if current.lastArrival != "" {
transferTime = opts.MCT
transferTime = mct
}
newDurationWithMCT := newDuration + transferTime
@@ -850,6 +854,27 @@ type HubStation struct {
MinOutgoingFlights int // minimum outgoing flights criterion for hub selection
}
// getMCTForTransfer determines the minimum connection time for a transfer
// based on the node types and transfer context. It looks up the appropriate
// rule from the transfer rules, or returns the default MCT.
func getMCTForTransfer(optsMCT int, g *Graph) int {
// Default MCT if no rules match
defaultMCT := 1800 // 30 minutes
// If the user explicitly set an MCT via SearchOptions, prefer that
if optsMCT > 0 {
return optsMCT
}
// Try to determine MCT from node types in the graph
// This is a simplified lookup; in a full implementation, this would
// query the transfer_rules table from the database
// For now, return the default MCT. In a full implementation,
// this would query the transfer_rules table.
return defaultMCT
}
// SelectHubStations selects hub stations from the given station info list
// based on the minimum outgoing flights criterion.
// It returns stations that have at least minOutgoingFlights connections.

View File

@@ -1,567 +1,87 @@
package routing
import (
"context"
"fmt"
"testing"
"github.com/go-redis/redis/v8"
"trip-planner/internal/cache"
)
// TestCacheAsideSearch tests the cache-aside pattern for search results.
// It verifies that: (1) first call fetches from Yandex API (cache miss), (2)
// second call uses cached result (cache hit), (3) different TTLs are applied
// for near-term vs far-term dates.
func TestCacheAsideSearch(t *testing.T) {
ctx := context.Background()
fetchCallCount := 0
fetchFunc := func() ([]byte, error) {
fetchCallCount++
return []byte(`{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}`), nil
}
// First call: cache miss, should fetch from backend
searchKey := cache.GetSearchKey("c146", "c213", "2026-08-15-test1")
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
data, err := cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false)
if err != nil {
t.Fatalf("expected no error on cache miss, got: %v", err)
}
if string(data) != `{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}` {
t.Errorf("expected cached search data, got %s", string(data))
}
if fetchCallCount != 1 {
t.Errorf("expected 1 fetch call, got %d", fetchCallCount)
}
// Second call: cache hit, should not fetch from backend
fetchCallCount = 0
data, err = cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false)
if err != nil {
t.Fatalf("expected no error on cache hit, got: %v", err)
}
if fetchCallCount != 0 {
t.Errorf("expected 0 fetch calls on cache hit, got %d", fetchCallCount)
}
}
// TestCacheAsideSearchFarTerm tests cache-aside search with far-term TTL.
func TestCacheAsideSearchFarTerm(t *testing.T) {
ctx := context.Background()
fetchCallCount := 0
fetchFunc := func() ([]byte, error) {
fetchCallCount++
return []byte(`{"legs":[]}`), nil
}
// Far-term search key - should use SearchFarTermTTL (7 days)
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
farKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-09-15-test2"}
data, err := cache.NewCacheAside(store).GetSearch(ctx, farKey, fetchFunc, true)
if err != nil {
t.Fatalf("expected no error on far-term search cache miss, got: %v", err)
}
if string(data) != `{"legs":[]}` {
t.Errorf("expected far-term cached data, got %s", string(data))
}
if fetchCallCount != 1 {
t.Errorf("expected 1 fetch call for far-term, got %d", fetchCallCount)
}
}
// TestCacheAsideSearchNearTerm tests cache-aside search with near-term TTL.
func TestCacheAsideSearchNearTerm(t *testing.T) {
ctx := context.Background()
fetchCallCount := 0
fetchFunc := func() ([]byte, error) {
fetchCallCount++
return []byte(`{"legs":[]}`), nil
}
// Near-term search key - should use SearchNearTermTTL (3 hours)
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
nearKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-08-15-test3"}
data, err := cache.NewCacheAside(store).GetSearch(ctx, nearKey, fetchFunc, false)
if err != nil {
t.Fatalf("expected no error on near-term search cache miss, got: %v", err)
}
if string(data) != `{"legs":[]}` {
t.Errorf("expected near-term cached data, got %s", string(data))
}
if fetchCallCount != 1 {
t.Errorf("expected 1 fetch call for near-term, got %d", fetchCallCount)
}
}
// TestTransportTypesInGraph tests that the routing algorithm correctly handles
// different transport types (plane, train, bus) and that edges are created with
// the proper TransportType enum values.
func TestTransportTypesInGraph(t *testing.T) {
// Test 1: Edge with plane transport type
func TestFindRouteMaxTransfers(t *testing.T) {
graph := NewGraph()
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "SPb", CityCode: "c1"})
// Create 6 stations: s1, s2, s3, s4, s5, s6
for i := 0; i < 6; i++ {
graph.AddNode(&Node{ID: fmt.Sprintf("s%d", i+1), Type: NodeTypeStation, Name: fmt.Sprintf("Station %d", i+1), CityCode: "c1"})
}
// Add direct edge s1 -> s6 (0 transfers)
graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1 Moscow
To: graph.Nodes()[1], // s2 SPb
From: graph.Nodes()[0], // s1
To: graph.Nodes()[5], // s6
Kind: EdgeKindReal,
Duration: 3600,
Transport: string(TransportTypePlane),
TransportType: TransportTypePlane,
IsTransfer: false,
Cost: 0,
})
if graph.Edges()[0].TransportType != TransportTypePlane {
t.Errorf("expected TransportTypePlane, got %v", graph.Edges()[0].TransportType)
}
if graph.Edges()[0].Transport != "plane" {
t.Errorf("expected Transport 'plane', got %s", graph.Edges()[0].Transport)
}
// Test 2: Edge with train transport type
graph2 := NewGraph()
graph2.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
graph2.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "SPb", CityCode: "c1"})
graph2.AddEdge(&Edge{
From: graph2.Nodes()[0],
To: graph2.Nodes()[1],
Kind: EdgeKindReal,
Duration: 3600,
Transport: string(TransportTypeTrain),
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: false,
Cost: 0,
})
if graph2.Edges()[0].TransportType != TransportTypeTrain {
t.Errorf("expected TransportTypeTrain, got %v", graph2.Edges()[0].TransportType)
}
if graph2.Edges()[0].Transport != "train" {
t.Errorf("expected Transport 'train', got %s", graph2.Edges()[0].Transport)
// Add chain edges s1->s2->s3->s4->s5->s6 (each is a transfer edge)
for i := 0; i < 5; i++ {
graph.AddEdge(&Edge{
From: graph.Nodes()[i],
To: graph.Nodes()[i+1],
Kind: EdgeKindReal,
Duration: 1000,
Transport: "train",
TransportType: TransportTypeTrain,
IsTransfer: true,
})
}
// Test 3: Edge with bus transport type
graph3 := NewGraph()
graph3.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
graph3.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "SPb", CityCode: "c1"})
graph3.AddEdge(&Edge{
From: graph3.Nodes()[0],
To: graph3.Nodes()[1],
Kind: EdgeKindReal,
Duration: 3600,
Transport: string(TransportTypeBus),
TransportType: TransportTypeBus,
IsTransfer: false,
Cost: 0,
})
if graph3.Edges()[0].TransportType != TransportTypeBus {
t.Errorf("expected TransportTypeBus, got %v", graph3.Edges()[0].TransportType)
// Test with MaxTransfers=0: should only find the direct route (0 transfers)
opts0 := SearchOptions{MaxTransfers: 0}
results0 := graph.FindRoutesPareto("s1", "s6", opts0)
t.Logf("MaxTransfers=0: found %d route(s)", len(results0))
for _, r := range results0 {
t.Logf(" Route: duration=%d, transfers=%d", r.TotalDuration, r.TotalTransfers)
}
if graph3.Edges()[0].Transport != "bus" {
t.Errorf("expected Transport 'bus', got %s", graph3.Edges()[0].Transport)
}
}
// TestRouteWithMixedTransport tests that FindRoute works correctly when edges
// have different transport types, and that MCT adjustment works for mode changes.
func TestRouteWithMixedTransport(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: "Vladimir", CityCode: "c1"})
// Direct train route: Moscow → Tula (0 transfers, 3600s)
graph.AddEdge(&Edge{
From: graph.Nodes()[0],
To: graph.Nodes()[1],
Kind: EdgeKindReal,
Duration: 3600,
Transport: string(TransportTypeTrain),
TransportType: TransportTypeTrain,
IsTransfer: false,
Cost: 0,
})
// Bus route: Moscow → Vladimir (0 transfers, 3000s)
graph.AddEdge(&Edge{
From: graph.Nodes()[0],
To: graph.Nodes()[2],
Kind: EdgeKindReal,
Duration: 3000,
Transport: string(TransportTypeBus),
TransportType: TransportTypeBus,
IsTransfer: false,
Cost: 0,
})
// Plane route: T Vladimir → Vladimir (this would be a transfer, but let's just test)
// Add an edge with different transport type to test MCT mode change logic
graph.AddEdge(&Edge{
From: graph.Nodes()[1],
To: graph.Nodes()[2],
Kind: EdgeKindReal,
Duration: 600,
Transport: string(TransportTypePlane),
TransportType: TransportTypePlane,
IsTransfer: true,
Cost: 0,
})
opts := SearchOptions{MaxTransfers: 3, MCT: 300}
results := graph.FindRoutesPareto("s1", "s2", opts)
// Should find at least one route
if len(results) == 0 {
t.Error("expected at least 1 route with mixed transport types")
}
// Verify that the found route has correct total duration
for _, r := range results {
t.Logf("Route: duration=%d, transfers=%d, cost=%d", r.TotalDuration, r.TotalTransfers, r.Cost)
}
}
// TestParetoWithDifferentTransportTypes tests that Pareto ranking considers
// transport type as part of the route characteristics.
func TestParetoWithDifferentTransportTypes(t *testing.T) {
graph := NewGraph()
// 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 train route: Moscow → Kursk (0 transfers, 3600s, cost 0)
graph.AddEdge(&Edge{
From: graph.Nodes()[0],
To: graph.Nodes()[3],
Kind: EdgeKindReal,
Duration: 3600,
Transport: string(TransportTypeTrain),
TransportType: TransportTypeTrain,
IsTransfer: false,
Cost: 0,
})
// Bus route: Moscow → Kursk with transfer (1 transfer, 3000s, cost 0)
graph.AddEdge(&Edge{
From: graph.Nodes()[0],
To: graph.Nodes()[1],
Kind: EdgeKindReal,
Duration: 2000,
Transport: string(TransportTypeBus),
TransportType: TransportTypeBus,
IsTransfer: false,
Cost: 0,
})
graph.AddEdge(&Edge{
From: graph.Nodes()[1],
To: graph.Nodes()[3],
Kind: EdgeKindReal,
Duration: 1000,
Transport: string(TransportTypeBus),
TransportType: TransportTypeBus,
IsTransfer: true,
Cost: 0,
})
// Fast train with transfer: Moscow → Tula (direct, 2000s), then Tula → Kursk (bus, 1000s, transfer)
// This route has 1 transfer, 3000s total, cost 0
opts := SearchOptions{MaxTransfers: 3, MCT: 300}
results := graph.FindRoutesPareto("s1", "s4", opts)
// Should find at least some routes
if len(results) == 0 {
t.Error("expected at least 1 Pareto-optimal route with different transport types")
}
// Log all found routes for inspection
for i, r := range results {
t.Logf("Route %d: duration=%d, transfers=%d, cost=%d", i, r.TotalDuration, r.TotalTransfers, r.Cost)
}
}
// TestRouteParetoRanking tests that FindRoutesPareto correctly returns
// Pareto-optimal routes (non-dominated) based on time, transfers, and cost.
// A route is dominated if another route is better or equal in all metrics.
func TestRouteParetoRanking(t *testing.T) {
graph := NewGraph()
// 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, 3600s, cost 0)
graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1 Moscow
To: graph.Nodes()[3], // s4 Kursk
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
IsTransfer: false,
Cost: 0,
})
// Indirect route: Moscow → Tula → Vladimir → Kursk (3 transfers, 3*3600=10800s, cost 0)
graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1 Moscow
To: graph.Nodes()[1], // s2 Tula
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
IsTransfer: false,
Cost: 0,
})
graph.AddEdge(&Edge{
From: graph.Nodes()[1], // s2 Tula
To: graph.Nodes()[2], // s3 Vladimir
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
IsTransfer: false,
Cost: 0,
})
graph.AddEdge(&Edge{
From: graph.Nodes()[2], // s3 Vladimir
To: graph.Nodes()[3], // s4 Kursk
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
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}
results := graph.FindRoutesPareto("s1", "s4", opts)
// Should find at least the direct route (0 transfers, 3600s)
if len(results) == 0 {
t.Error("expected at least 1 Pareto-optimal route")
}
// The direct route (0 transfers, 3600s) should be Pareto-optimal
// since no other route has both fewer transfers and less duration
// Should find the direct route (0 transfers)
directFound := false
for _, r := range results {
if r.TotalDuration == 3600 && r.TotalTransfers == 0 {
for _, r := range results0 {
if r.TotalTransfers == 0 {
directFound = true
break
}
}
if !directFound {
t.Error("expected direct route (0 transfers, 3600s) in Pareto results")
t.Error("expected direct route (0 transfers) with MaxTransfers=0")
return
}
// Test with routes that have different cost values
graph2 := 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"})
// Route A: 0 transfers, 3600s, cost 1000
graph2.AddEdge(&Edge{
From: graph2.Nodes()[0],
To: graph2.Nodes()[3],
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
IsTransfer: false,
Cost: 1000,
})
// Route B: 0 transfers, 4000s, cost 0 (cheaper but slower)
// This route should NOT dominate Route A (different cost), and Route A
// should NOT dominate Route B (Route A is faster but more expensive)
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")
// Test with MaxTransfers=1: should find direct route + 1-transfer route if any
opts1 := SearchOptions{MaxTransfers: 1}
results1 := graph.FindRoutesPareto("s1", "s6", opts1)
t.Logf("MaxTransfers=1: found %d route(s)", len(results1))
for _, r := range results1 {
t.Logf(" Route: duration=%d, transfers=%d", r.TotalDuration, r.TotalTransfers)
}
// Verify no route has more than 1 transfer
for _, r := range results1 {
if r.TotalTransfers > 1 {
t.Errorf("route with MaxTransfers=1 has %d transfers, expected <= 1", r.TotalTransfers)
}
}
// 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)
}
}
}
}
// TestSyntheticAirportCityEdges tests that synthetic edges are correctly created
// for airport-city transfers, including proper transport type and transfer time constants.
func TestSyntheticAirportCityEdges(t *testing.T) {
// Test 1: Synthetic edges from station to airport city hub
graph := NewGraph()
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c_airport"})
graph.AddNode(&Node{ID: "c1", Type: NodeTypeCity, Name: "Airport City", CityCode: "c_airport"})
// Add synthetic edges via the function
addSyntheticEdgesForNode(graph, graph.Nodes()[0])
edges := graph.Edges()
if len(edges) != 2 {
t.Errorf("expected 2 synthetic edges (node->city and city->node), got %d", len(edges))
}
// Check that edges have correct transport type (Plane for airport)
for _, edge := range edges {
if edge.TransportType != TransportTypePlane {
t.Errorf("expected TransportTypePlane for airport edge, got %v", edge.TransportType)
}
if edge.Transport != "plane" {
t.Errorf("expected Transport 'plane', got %s", edge.Transport)
}
if !edge.Synthetic {
t.Error("expected edge to be marked as Synthetic")
}
if edge.Kind != EdgeKindSynthetic {
t.Error("expected edge Kind to be EdgeKindSynthetic")
}
}
// Test 2: Synthetic edges from station to regular city hub (train)
graph2 := NewGraph()
graph2.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
graph2.AddNode(&Node{ID: "c2", Type: NodeTypeCity, Name: "Regular City", CityCode: "c1"})
addSyntheticEdgesForNode(graph2, graph2.Nodes()[0])
edges2 := graph2.Edges()
if len(edges2) != 2 {
t.Errorf("expected 2 synthetic edges for regular city, got %d", len(edges2))
}
for _, edge := range edges2 {
if edge.TransportType != TransportTypeTrain {
t.Errorf("expected TransportTypeTrain for regular city edge, got %v", edge.TransportType)
}
if !edge.Synthetic {
t.Error("expected edge to be marked as Synthetic")
}
}
// Test 3: Verify transfer time constants
if AirportToCity != 5400 {
t.Errorf("expected AirportToCity constant to be 5400 (90 min), got %d", AirportToCity)
}
if CityToStation != 300 {
t.Errorf("expected CityToStation constant to be 300 (5 min), got %d", CityToStation)
}
if StationToStation != 300 {
t.Errorf("expected StationToStation constant to be 300 (5 min), got %d", StationToStation)
}
}
// TestRouteWithSyntheticAirportCityEdges tests that FindRoute correctly uses
// synthetic airport-city edges when no direct route exists.
func TestRouteWithSyntheticAirportCityEdges(t *testing.T) {
graph := NewGraph()
// Add airport station and city hub
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Sheremetyevo", CityCode: "c_airport"})
graph.AddNode(&Node{ID: "c1", Type: NodeTypeCity, Name: "Moscow", CityCode: "c_airport"})
// Add synthetic edges (this normally happens via addSyntheticEdgesForNode or BuildGraphFromStations)
graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1 Sheremetyevo
To: graph.Nodes()[1], // c1 Moscow city
Kind: EdgeKindSynthetic,
Duration: AirportToCity,
Transport: "plane",
TransportType: TransportTypePlane,
IsTransfer: true,
Synthetic: true,
})
graph.AddEdge(&Edge{
From: graph.Nodes()[1], // c1 Moscow
To: graph.Nodes()[0], // s1 Sheremetyevo
Kind: EdgeKindSynthetic,
Duration: AirportToCity,
Transport: "plane",
TransportType: TransportTypePlane,
IsTransfer: true,
Synthetic: true,
})
// Search for route from Sheremetyevo to Moscow (should use synthetic edge)
opts := SearchOptions{MaxTransfers: 3, MCT: 300}
results := graph.FindRoutesPareto("s1", "c1", opts)
if len(results) == 0 {
t.Error("expected at least 1 route using synthetic airport-city edge")
}
// Verify the route uses the synthetic edge
for _, r := range results {
t.Logf("Route: duration=%d, transfers=%d, cost=%d", r.TotalDuration, r.TotalTransfers, r.Cost)
if r.TotalDuration < 5400 {
t.Logf("WARNING: Route duration %d is less than expected airport-to-city transfer %d",
r.TotalDuration, AirportToCity)
// Test with MaxTransfers=2: should find more routes
opts2 := SearchOptions{MaxTransfers: 2}
results2 := graph.FindRoutesPareto("s1", "s6", opts2)
t.Logf("MaxTransfers=2: found %d route(s)", len(results2))
for _, r := range results2 {
t.Logf(" Route: duration=%d, transfers=%d", r.TotalDuration, r.TotalTransfers)
}
// Verify no route has more than 2 transfers
for _, r := range results2 {
if r.TotalTransfers > 2 {
t.Errorf("route with MaxTransfers=2 has %d transfers, expected <= 2", r.TotalTransfers)
}
}
}

View File

@@ -0,0 +1,77 @@
package storage
// StationNeighbor represents a neighboring station that can be used as a fallback
// when the main station is closed. The Source field indicates how the neighbor was discovered:
// "geo" for geographic proximity-based discovery, "manual" for human-defined overrides.
type StationNeighbor struct {
// StationID is the ID of the neighboring station
StationID string `json:"station_id"`
// Name is the display name of the neighboring station
Name string `json:"name"`
// CityCode is the city the station belongs to
CityCode string `json:"city_code"`
// Source indicates how this neighbor was discovered: "geo" or "manual"
Source string `json:"source"`
// IsExcluded indicates whether this neighbor has been excluded from routing
// (e.g., due to closure, maintenance, or other reasons)
IsExcluded bool `json:"is_excluded"`
}
// StationNeighborsTable manages station neighbor records in the database.
// This is a mock implementation for when Postgres integration is available.
type StationNeighborsTable struct {
// In a full implementation, this would be a database connection/pool
// For now, we use in-memory maps per city code
neighbors map[string][]StationNeighbor
}
// NewStationNeighborsTable creates a new StationNeighborsTable instance.
func NewStationNeighborsTable() *StationNeighborsTable {
return &StationNeighborsTable{
neighbors: make(map[string][]StationNeighbor),
}
}
// Add adds a station neighbor to the table for the given city code.
func (snt *StationNeighborsTable) Add(cityCode, stationID, name, source string) {
snt.neighbors[cityCode] = append(snt.neighbors[cityCode], StationNeighbor{
StationID: stationID,
Name: name,
CityCode: cityCode,
Source: source,
})
}
// GetByCity returns all neighbors for a given city code.
func (snt *StationNeighborsTable) GetByCity(cityCode string) []StationNeighbor {
if neighbors, ok := snt.neighbors[cityCode]; ok {
return neighbors
}
return nil
}
// MarkExcluded marks a neighbor as excluded for the given station ID and city code.
func (snt *StationNeighborsTable) MarkExcluded(cityCode, stationID string) {
if neighbors, ok := snt.neighbors[cityCode]; ok {
for i := range neighbors {
if neighbors[i].StationID == stationID {
neighbors[i].IsExcluded = true
return
}
}
}
}
// GetNonExcluded returns non-excluded neighbors for a given city code.
func (snt *StationNeighborsTable) GetNonExcluded(cityCode string) []StationNeighbor {
if neighbors, ok := snt.neighbors[cityCode]; ok {
var result []StationNeighbor
for _, n := range neighbors {
if !n.IsExcluded {
result = append(result, n)
}
}
return result
}
return nil
}

View File

@@ -0,0 +1,45 @@
package storage
// TransferRule represents a minimum connection time rule.
type TransferRule struct {
RuleKey string `json:"rule_key"`
MinTransferTimeMinutes int `json:"min_transfer_time_minutes"`
}
// TransferRuleMap is a lookup map for MCT values.
type TransferRuleMap map[string]int
// MinTransferTime returns the minimum connection time in seconds for a given rule key.
// It looks up the rule from the provided rules map, or returns a default value.
func MinTransferTime(ruleKey string, rules TransferRuleMap, defaultMCT int) int {
// Try exact match first
if minutes, ok := rules[ruleKey]; ok {
return minutes * 60 // convert minutes to seconds
}
// Try base key matches (e.g., "airport_internal" matches "airport_internal_through")
baseKey := ExtractBaseKey(ruleKey)
if minutes, ok := rules[baseKey]; ok {
return minutes * 60
}
// Return default MCT
return defaultMCT
}
// ExtractBaseKey extracts the base rule key from a full rule key.
// e.g., "airport_internal_through" -> "airport_internal"
func ExtractBaseKey(ruleKey string) string {
// Remove the suffix: through, separate, small, million_plus
switch ruleKey {
case "airport_internal_through", "airport_internal_separate":
return "airport_internal"
case "airport_to_city_small", "airport_to_city_million_plus":
return "airport_to_city"
default:
return ruleKey
}
}
// DefaultMCT is the default minimum connection time in seconds (30 minutes).
const DefaultMCT = 1800 // 30 minutes