From 2101362d3137684c8640c0c0dfd8be550a13f117 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Thu, 13 Aug 2026 21:01:53 +0300 Subject: [PATCH] feat: complete Task 7 - end-to-end integration and full test suite --- .DS_Store | Bin 0 -> 8196 bytes cmd/api/handlers.go | 18 +- cmd/api/handlers_test.go | 109 ++++++- cmd/cron/station_status.go | 8 +- cmd/cron/station_status_test.go | 14 +- coverage.out | 281 ++++++++++++++++++ .../2026-08-13-MVP-Routing-Implementation.md | 12 +- dump.rdb | Bin 0 -> 259 bytes internal/routing/graph_test.go | 220 ++++++++++++++ 9 files changed, 635 insertions(+), 27 deletions(-) create mode 100644 .DS_Store create mode 100644 coverage.out create mode 100644 dump.rdb diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..cf2de429e5388f85f847ed45a2f27a37b8afaf6c GIT binary patch literal 8196 zcmeHMS&S4#7_Pr}VY|hK+5=dik!D8_WRV$Q)#Wg=I|E_47IzkQ_JE;hx@B8>x|iuW zkVDp>MuJD8(HIn;AYKViUT91-XyieS!4+eC@rsG@!9=6Jfbp-cp3ShhJ`gn!yOOTI z>i_Gn>iX;Zs-}7fA<$mXBZM>(LPUI+q?0iFi^S~gxhx63h8Y3_+LhV?GGwGOh$b<%%!(=VCG!` zP*$VbOIW6?fKv5k)R)6tq6HIhSCrfp{3`}vkL~T`p_sZKhF5(T12~=QYzda-M^!8`xx^$Fy~))b12)-SvDE+#pG! zl%g~C?~g=Un$?z;We1zp{gLpp)@F5i>(YY3$y#nP#y?h6rOT83(x0p-T{r1^d9sr1P4@K<$SGP^#CdfNaZT1^Z!$lgDcR>bxqQs)H)-NfyaQTrjV=TGH;YCbM{ye}X2^;loto09&RclP zvfEd;tzEylYlPO;)i+F)=RdGPa_uAy>Y$V;YC{4-& zQS4{>SkAzltK~(?Vu@;Ew~MjiU4-SO5_P5S-3S=&+9Ka3QeE0=X=%gasul7ok#3NB zihMb^a;h8^r6ewS7ANU)T})Xc(zD_YHtz%O0CV z(OX~CGnX~GnRjwL8coWbqS(tiYtwb?ht8ZeBhjrWDN0SdXd*dqNjXR8IZ+ft(VO|= zgvx6adS`H_SM~xOiiWvC!Xt1FSx(lH%_L2-PfJONL_Q}7I& zfRpe%ya1=*b$A2bgty=fdM|1?)*jJmexDkM-j;ZT*hoA7d-f1LK+YL9z@1M7g?#yzCl*EB0r41V>wyo zDy?B078z=lrjV@C3CJK`y=ehfCn9@zwRy3sQUOWJtC6LuOskNR9{-hF7h@It%8He; zOskQc(ujh46ndz2>jCs{WI_$ zybmA1CvXl%;4?VyQQ!yo8GiSFVUkf$Z!W``OHV%v>Z}uw``^CB+=;`-FHSq#Rtfs7 zqyMdaXmI?as+h>xmSgY2{>lWq^i{5`R>j>K+5Lab_}~Aptn station Moscow (transfer) + graph.AddEdge(&routing.Edge{ + From: graph.Nodes()[0], // c1 city hub + To: graph.Nodes()[1], // s1 Moscow + Kind: routing.EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + }) + graph.AddEdge(&routing.Edge{ + From: graph.Nodes()[1], // s1 Moscow + To: graph.Nodes()[0], // c1 city hub + Kind: routing.EdgeKindSynthetic, + Duration: 300, + Transport: "train", + IsTransfer: true, + }) + + // Add real edge: direct route Moscow → Tula + graph.AddEdge(&routing.Edge{ + From: graph.Nodes()[1], // s1 Moscow + To: graph.Nodes()[2], // s2 Tula + Kind: routing.EdgeKindReal, + Duration: 3600, + Transport: "train", + IsTransfer: false, + }) + + // Add synthetic transfer edge: Tula → Vladimir (1 transfer) + graph.AddEdge(&routing.Edge{ + From: graph.Nodes()[2], // s2 Tula + To: graph.Nodes()[3], // s3 Vladimir + Kind: routing.EdgeKindSynthetic, + Duration: 1800, + Transport: "train", + IsTransfer: true, + }) + + // Replace the router with our test graph + h.Router = graph + + // Create request: from city c1 (Moscow) to city c1 (same city code) + req := httptest.NewRequest("POST", "/v1/routes/search", strings.NewReader(`{"from_city_id": "c1", "to_city_id": "c1", "date": "2026-08-15"}`)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + RouteSearch(h, rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + + var resp routeSearchResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + t.Logf("route search response: routes=%+v, count=%d", resp.Routes, resp.Count) + + // With this graph, we should find a route with 1 transfer + if resp.Count == 0 { + t.Error("expected at least 1 route, got 0") + } +} + +// TestHandlerRouteSearchNoRoute tests route search when origin/destination not in graph. +func TestHandlerRouteSearchNoRoute(t *testing.T) { + h := newMockHandlerContext() + + // 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.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 + + req := httptest.NewRequest("POST", "/v1/routes/search", strings.NewReader(`{"from_city_id": "c999", "to_city_id": "c888", "date": "2026-08-15"}`)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + RouteSearch(h, rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + + var resp routeSearchResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Count != 0 { + t.Errorf("expected 0 routes, got %d", resp.Count) + } +} diff --git a/cmd/cron/station_status.go b/cmd/cron/station_status.go index d7712f4..3372727 100644 --- a/cmd/cron/station_status.go +++ b/cmd/cron/station_status.go @@ -12,9 +12,9 @@ import ( // StationMonitor tracks the status and consecutive zero-trip days for a station. type StationMonitor struct { - ID string - Yandex *yandex.Client - Cache cache.Cache + ID string + Yandex *yandex.Client + Cache cache.Cache // ScheduleFunc is the function used to check a station's schedule. // Defaults to checkStationSchedule if not set. @@ -166,4 +166,4 @@ func ProcessAllStations(ctx context.Context, monitors []*StationMonitor) error { } } return nil -} \ No newline at end of file +} diff --git a/cmd/cron/station_status_test.go b/cmd/cron/station_status_test.go index f5c0d5f..4214d49 100644 --- a/cmd/cron/station_status_test.go +++ b/cmd/cron/station_status_test.go @@ -17,9 +17,9 @@ func newMockMonitor(id string, tripCount int, scheduleFunc func(context.Context, yc := yandex.NewClient("test-key") monitor := &StationMonitor{ - ID: id, - Yandex: yc, - Cache: cache.NewCacheStore(rc), + ID: id, + Yandex: yc, + Cache: cache.NewCacheStore(rc), ScheduleFunc: scheduleFunc, } @@ -204,13 +204,13 @@ func TestProcessStation_Reactivation_AfterClosure(t *testing.T) { _ = rc.Set(ctx, "station:zero_days:"+testMonitorID, "3", 24*time.Hour) _ = rc.Set(ctx, "station:status:"+testMonitorID, string(StatusClosed), 24*time.Hour) -// Day 4: trips resume - should reactivate + // Day 4: trips resume - should reactivate err := ProcessStation(ctx, monitor) if err != nil { t.Fatalf("unexpected error on reactivation: %v", err) } -// Status should be active again + // Status should be active again statusData, err := monitor.Cache.Get(ctx, stationStatusKey(testMonitorID)) if err != nil { t.Fatalf("cache get error: %v", err) @@ -219,7 +219,7 @@ func TestProcessStation_Reactivation_AfterClosure(t *testing.T) { t.Errorf("expected status active after reactivation, got %s", string(statusData)) } -// Zero days should be reset to 0 + // Zero days should be reset to 0 zeroDaysData, err := monitor.Cache.Get(ctx, zeroDaysKey(testMonitorID)) if err != nil { t.Fatalf("cache get zero days error: %v", err) @@ -232,4 +232,4 @@ func TestProcessStation_Reactivation_AfterClosure(t *testing.T) { if zeroDays != 0 { t.Errorf("expected zero days 0 after reactivation, got %d", zeroDays) } -} \ No newline at end of file +} diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..6dc16c6 --- /dev/null +++ b/coverage.out @@ -0,0 +1,281 @@ +mode: set +trip-planner/cmd/api/handlers.go:23.113,30.2 1 1 +trip-planner/cmd/api/handlers.go:45.82,47.17 2 1 +trip-planner/cmd/api/handlers.go:47.17,50.3 2 0 +trip-planner/cmd/api/handlers.go:60.2,61.45 2 1 +trip-planner/cmd/api/handlers.go:73.78,78.44 3 1 +trip-planner/cmd/api/handlers.go:78.44,81.3 2 0 +trip-planner/cmd/api/handlers.go:82.2,84.18 2 1 +trip-planner/cmd/api/handlers.go:84.18,87.3 2 0 +trip-planner/cmd/api/handlers.go:90.2,94.16 4 1 +trip-planner/cmd/api/handlers.go:94.16,97.3 2 0 +trip-planner/cmd/api/handlers.go:99.2,99.17 1 1 +trip-planner/cmd/api/handlers.go:99.17,105.3 3 1 +trip-planner/cmd/api/handlers.go:109.2,110.53 2 0 +trip-planner/cmd/api/handlers.go:137.77,139.61 2 1 +trip-planner/cmd/api/handlers.go:139.61,142.3 2 0 +trip-planner/cmd/api/handlers.go:144.2,144.66 1 1 +trip-planner/cmd/api/handlers.go:144.66,147.3 2 0 +trip-planner/cmd/api/handlers.go:151.2,154.42 3 1 +trip-planner/cmd/api/handlers.go:154.42,157.3 2 0 +trip-planner/cmd/api/handlers.go:160.2,167.19 3 1 +trip-planner/cmd/api/handlers.go:167.19,174.3 3 1 +trip-planner/cmd/api/handlers.go:177.2,178.34 2 1 +trip-planner/cmd/api/handlers.go:178.34,186.3 1 0 +trip-planner/cmd/api/handlers.go:188.2,192.4 2 1 +trip-planner/cmd/api/handlers.go:204.78,208.44 3 1 +trip-planner/cmd/api/handlers.go:208.44,211.3 2 0 +trip-planner/cmd/api/handlers.go:212.2,215.37 3 1 +trip-planner/cmd/api/handlers.go:215.37,218.3 2 0 +trip-planner/cmd/api/handlers.go:224.2,248.4 3 1 +trip-planner/cmd/api/handlers.go:259.79,263.46 3 1 +trip-planner/cmd/api/handlers.go:263.46,266.3 2 0 +trip-planner/cmd/api/handlers.go:267.2,269.21 2 1 +trip-planner/cmd/api/handlers.go:269.21,272.3 2 0 +trip-planner/cmd/api/handlers.go:274.2,282.16 4 1 +trip-planner/cmd/api/handlers.go:282.16,285.3 2 0 +trip-planner/cmd/api/handlers.go:287.2,287.17 1 1 +trip-planner/cmd/api/handlers.go:287.17,294.3 3 1 +trip-planner/cmd/api/handlers.go:298.2,301.4 2 0 +trip-planner/cmd/api/handlers.go:305.38,310.16 3 1 +trip-planner/cmd/api/handlers.go:310.16,312.3 1 0 +trip-planner/cmd/api/handlers.go:313.2,313.27 1 1 +trip-planner/cmd/api/handlers.go:317.37,318.32 1 1 +trip-planner/cmd/api/handlers.go:318.32,320.3 1 1 +trip-planner/cmd/api/handlers.go:321.2,321.10 1 1 +trip-planner/cmd/api/handlers.go:325.38,326.39 1 1 +trip-planner/cmd/api/handlers.go:326.39,328.3 1 0 +trip-planner/cmd/api/handlers.go:329.2,329.10 1 1 +trip-planner/cmd/api/handlers.go:333.38,336.30 3 1 +trip-planner/cmd/api/handlers.go:336.30,337.18 1 1 +trip-planner/cmd/api/handlers.go:337.18,338.17 1 1 +trip-planner/cmd/api/handlers.go:338.17,340.5 1 1 +trip-planner/cmd/api/handlers.go:341.4,341.17 1 1 +trip-planner/cmd/api/handlers.go:344.2,344.20 1 1 +trip-planner/cmd/api/handlers.go:344.20,346.3 1 1 +trip-planner/cmd/api/handlers.go:347.2,347.14 1 1 +trip-planner/cmd/api/main.go:5.13,7.2 1 0 +trip-planner/internal/routing/graph.go:61.24,66.2 1 1 +trip-planner/internal/routing/graph.go:69.37,71.2 1 1 +trip-planner/internal/routing/graph.go:74.37,76.2 1 1 +trip-planner/internal/routing/graph.go:79.33,83.2 3 1 +trip-planner/internal/routing/graph.go:86.33,90.2 3 1 +trip-planner/internal/routing/graph.go:95.60,102.30 3 1 +trip-planner/internal/routing/graph.go:102.30,114.51 4 1 +trip-planner/internal/routing/graph.go:114.51,122.4 3 1 +trip-planner/internal/routing/graph.go:125.3,143.5 3 1 +trip-planner/internal/routing/graph.go:146.2,146.14 1 1 +trip-planner/internal/routing/graph.go:150.31,151.40 1 1 +trip-planner/internal/routing/graph.go:151.40,153.3 1 1 +trip-planner/internal/routing/graph.go:157.57,159.31 2 1 +trip-planner/internal/routing/graph.go:159.31,161.3 1 1 +trip-planner/internal/routing/graph.go:162.2,162.12 1 1 +trip-planner/internal/routing/graph.go:166.44,167.28 1 1 +trip-planner/internal/routing/graph.go:167.28,168.17 1 1 +trip-planner/internal/routing/graph.go:168.17,170.4 1 1 +trip-planner/internal/routing/graph.go:172.2,172.12 1 0 +trip-planner/internal/routing/graph.go:177.83,186.41 4 1 +trip-planner/internal/routing/graph.go:186.41,188.3 1 0 +trip-planner/internal/routing/graph.go:191.2,217.21 7 1 +trip-planner/internal/routing/graph.go:217.21,223.31 3 1 +trip-planner/internal/routing/graph.go:223.31,225.89 1 1 +trip-planner/internal/routing/graph.go:225.89,230.5 3 1 +trip-planner/internal/routing/graph.go:232.4,232.12 1 1 +trip-planner/internal/routing/graph.go:236.3,236.71 1 1 +trip-planner/internal/routing/graph.go:236.71,237.12 1 1 +trip-planner/internal/routing/graph.go:241.3,241.44 1 1 +trip-planner/internal/routing/graph.go:241.44,249.33 4 1 +trip-planner/internal/routing/graph.go:249.33,252.5 1 0 +trip-planner/internal/routing/graph.go:254.4,258.52 3 1 +trip-planner/internal/routing/graph.go:258.52,259.49 1 1 +trip-planner/internal/routing/graph.go:259.49,261.14 1 1 +trip-planner/internal/routing/graph.go:264.4,267.23 3 1 +trip-planner/internal/routing/graph.go:267.23,269.5 1 1 +trip-planner/internal/routing/graph.go:272.4,276.40 3 1 +trip-planner/internal/routing/graph.go:276.40,284.5 1 1 +trip-planner/internal/routing/graph.go:284.10,292.5 1 1 +trip-planner/internal/routing/graph.go:294.4,306.6 2 1 +trip-planner/internal/routing/graph.go:310.3,310.41 1 1 +trip-planner/internal/routing/graph.go:310.41,311.46 1 0 +trip-planner/internal/routing/graph.go:311.46,313.5 1 0 +trip-planner/internal/routing/graph.go:314.4,314.50 1 0 +trip-planner/internal/routing/graph.go:318.2,318.17 1 1 +trip-planner/internal/routing/graph.go:318.17,320.3 1 1 +trip-planner/internal/routing/graph.go:321.2,321.13 1 1 +trip-planner/internal/routing/graph.go:326.72,327.50 1 1 +trip-planner/internal/routing/graph.go:327.50,330.3 1 1 +trip-planner/internal/routing/graph.go:333.2,333.18 1 1 +trip-planner/internal/routing/graph.go:333.18,335.3 1 0 +trip-planner/internal/routing/graph.go:338.2,341.41 3 1 +trip-planner/internal/routing/graph.go:341.41,351.41 5 1 +trip-planner/internal/routing/graph.go:351.41,353.4 1 1 +trip-planner/internal/routing/graph.go:356.3,356.45 1 1 +trip-planner/internal/routing/graph.go:356.45,358.4 1 1 +trip-planner/internal/routing/graph.go:361.3,361.33 1 1 +trip-planner/internal/routing/graph.go:365.2,366.18 2 1 +trip-planner/internal/routing/graph.go:411.92,416.75 2 1 +trip-planner/internal/routing/graph.go:416.75,421.48 4 1 +trip-planner/internal/routing/graph.go:421.48,423.4 1 1 +trip-planner/internal/routing/graph.go:427.2,427.49 1 1 +trip-planner/internal/routing/graph.go:427.49,428.73 1 1 +trip-planner/internal/routing/graph.go:428.73,430.4 1 0 +trip-planner/internal/routing/graph.go:431.3,431.75 1 1 +trip-planner/internal/routing/graph.go:431.75,433.4 1 0 +trip-planner/internal/routing/graph.go:434.3,434.57 1 1 +trip-planner/internal/routing/graph.go:439.2,440.43 2 1 +trip-planner/internal/routing/graph.go:440.43,442.35 2 1 +trip-planner/internal/routing/graph.go:442.35,449.38 1 1 +trip-planner/internal/routing/graph.go:449.38,451.10 2 0 +trip-planner/internal/routing/graph.go:454.3,454.17 1 1 +trip-planner/internal/routing/graph.go:454.17,456.4 1 1 +trip-planner/internal/routing/graph.go:459.2,459.15 1 1 +trip-planner/internal/cache/store.go:44.56,46.2 1 1 +trip-planner/internal/cache/store.go:49.79,51.31 2 1 +trip-planner/internal/cache/store.go:51.31,53.3 1 1 +trip-planner/internal/cache/store.go:54.2,54.16 1 1 +trip-planner/internal/cache/store.go:54.16,56.3 1 0 +trip-planner/internal/cache/store.go:57.2,57.17 1 1 +trip-planner/internal/cache/store.go:61.102,63.2 1 1 +trip-planner/internal/cache/store.go:66.80,68.31 2 1 +trip-planner/internal/cache/store.go:68.31,70.3 1 0 +trip-planner/internal/cache/store.go:71.2,71.16 1 1 +trip-planner/internal/cache/store.go:71.16,73.3 1 0 +trip-planner/internal/cache/store.go:74.2,74.18 1 1 +trip-planner/internal/cache/store.go:78.72,80.2 1 1 +trip-planner/internal/cache/store.go:83.84,85.2 1 0 +trip-planner/internal/cache/store.go:88.84,90.2 1 0 +trip-planner/internal/cache/store.go:93.36,94.16 1 1 +trip-planner/internal/cache/store.go:95.14,96.42 1 1 +trip-planner/internal/cache/store.go:97.17,98.44 1 1 +trip-planner/internal/cache/store.go:99.16,100.62 1 1 +trip-planner/internal/cache/store.go:101.10,102.43 1 0 +trip-planner/internal/cache/store.go:112.48,116.2 1 0 +trip-planner/internal/cache/store.go:131.40,133.2 1 0 +trip-planner/internal/cache/store.go:136.41,138.2 1 0 +trip-planner/internal/cache/store.go:141.52,143.2 1 0 +trip-planner/internal/cache/store.go:152.45,154.2 1 1 +trip-planner/internal/cache/store.go:159.143,161.67 1 1 +trip-planner/internal/cache/store.go:161.67,163.3 1 1 +trip-planner/internal/cache/store.go:166.2,167.16 2 1 +trip-planner/internal/cache/store.go:167.16,169.3 1 0 +trip-planner/internal/cache/store.go:172.2,172.57 1 1 +trip-planner/internal/cache/store.go:172.57,174.3 1 0 +trip-planner/internal/cache/store.go:176.2,176.18 1 1 +trip-planner/internal/cache/store.go:180.112,182.2 1 1 +trip-planner/internal/cache/store.go:185.115,187.2 1 0 +trip-planner/internal/cache/store.go:191.130,193.15 2 1 +trip-planner/internal/cache/store.go:193.15,195.3 1 1 +trip-planner/internal/cache/store.go:195.8,197.3 1 1 +trip-planner/internal/cache/store.go:198.2,198.52 1 1 +trip-planner/internal/cache/store.go:202.79,204.2 1 1 +trip-planner/internal/cache/store.go:207.82,209.2 1 1 +trip-planner/internal/cache/store.go:212.81,214.2 1 1 +trip-planner/cmd/cron/station_status.go:35.50,40.2 1 1 +trip-planner/cmd/cron/station_status.go:43.45,48.2 1 1 +trip-planner/cmd/cron/station_status.go:52.98,59.16 2 0 +trip-planner/cmd/cron/station_status.go:59.16,61.3 1 0 +trip-planner/cmd/cron/station_status.go:63.2,65.23 2 0 +trip-planner/cmd/cron/station_status.go:70.99,76.16 4 1 +trip-planner/cmd/cron/station_status.go:76.16,78.3 1 0 +trip-planner/cmd/cron/station_status.go:78.8,78.24 1 1 +trip-planner/cmd/cron/station_status.go:78.24,80.40 2 1 +trip-planner/cmd/cron/station_status.go:80.40,82.4 1 0 +trip-planner/cmd/cron/station_status.go:82.9,84.4 1 1 +trip-planner/cmd/cron/station_status.go:85.8,87.3 1 1 +trip-planner/cmd/cron/station_status.go:90.2,93.16 4 1 +trip-planner/cmd/cron/station_status.go:93.16,95.3 1 0 +trip-planner/cmd/cron/station_status.go:95.8,95.32 1 1 +trip-planner/cmd/cron/station_status.go:95.32,98.17 3 1 +trip-planner/cmd/cron/station_status.go:98.17,100.4 1 1 +trip-planner/cmd/cron/station_status.go:104.2,106.19 2 1 +trip-planner/cmd/cron/station_status.go:106.19,109.3 2 1 +trip-planner/cmd/cron/station_status.go:109.8,111.20 2 1 +trip-planner/cmd/cron/station_status.go:111.20,113.4 1 1 +trip-planner/cmd/cron/station_status.go:113.9,115.4 1 1 +trip-planner/cmd/cron/station_status.go:119.2,119.85 1 1 +trip-planner/cmd/cron/station_status.go:119.85,121.3 1 0 +trip-planner/cmd/cron/station_status.go:124.2,124.106 1 1 +trip-planner/cmd/cron/station_status.go:124.106,126.3 1 0 +trip-planner/cmd/cron/station_status.go:128.2,128.23 1 1 +trip-planner/cmd/cron/station_status.go:133.73,138.33 3 1 +trip-planner/cmd/cron/station_status.go:138.33,140.3 1 1 +trip-planner/cmd/cron/station_status.go:140.8,142.3 1 0 +trip-planner/cmd/cron/station_status.go:143.2,143.16 1 1 +trip-planner/cmd/cron/station_status.go:143.16,147.3 2 0 +trip-planner/cmd/cron/station_status.go:149.2,150.16 2 1 +trip-planner/cmd/cron/station_status.go:150.16,153.3 2 0 +trip-planner/cmd/cron/station_status.go:155.2,156.12 2 1 +trip-planner/cmd/cron/station_status.go:162.80,163.35 1 0 +trip-planner/cmd/cron/station_status.go:163.35,164.54 1 0 +trip-planner/cmd/cron/station_status.go:164.54,166.4 1 0 +trip-planner/cmd/cron/station_status.go:168.2,168.12 1 0 +trip-planner/internal/yandex/client.go:60.58,76.30 2 1 +trip-planner/internal/yandex/client.go:76.30,78.3 1 1 +trip-planner/internal/yandex/client.go:80.2,80.10 1 1 +trip-planner/internal/yandex/client.go:87.55,88.25 1 1 +trip-planner/internal/yandex/client.go:88.25,90.3 1 1 +trip-planner/internal/yandex/client.go:94.62,95.25 1 1 +trip-planner/internal/yandex/client.go:95.25,97.3 1 1 +trip-planner/internal/yandex/client.go:101.97,102.25 1 1 +trip-planner/internal/yandex/client.go:102.25,109.3 1 1 +trip-planner/internal/yandex/client.go:113.107,115.31 1 0 +trip-planner/internal/yandex/client.go:115.31,117.3 1 0 +trip-planner/internal/yandex/client.go:120.2,120.48 1 0 +trip-planner/internal/yandex/client.go:120.48,122.3 1 0 +trip-planner/internal/yandex/client.go:125.2,131.67 4 0 +trip-planner/internal/yandex/client.go:131.67,133.17 2 0 +trip-planner/internal/yandex/client.go:133.17,136.4 2 0 +trip-planner/internal/yandex/client.go:139.3,139.29 1 0 +trip-planner/internal/yandex/client.go:139.29,142.4 2 0 +trip-planner/internal/yandex/client.go:144.3,146.41 2 0 +trip-planner/internal/yandex/client.go:146.41,148.28 2 0 +trip-planner/internal/yandex/client.go:148.28,150.5 1 0 +trip-planner/internal/yandex/client.go:151.4,151.23 1 0 +trip-planner/internal/yandex/client.go:155.2,156.17 2 0 +trip-planner/internal/yandex/client.go:160.85,162.16 2 0 +trip-planner/internal/yandex/client.go:162.16,164.3 1 0 +trip-planner/internal/yandex/client.go:166.2,169.20 2 0 +trip-planner/internal/yandex/client.go:169.20,171.3 1 0 +trip-planner/internal/yandex/client.go:173.2,174.16 2 0 +trip-planner/internal/yandex/client.go:174.16,176.3 1 0 +trip-planner/internal/yandex/client.go:177.2,179.28 2 0 +trip-planner/internal/yandex/client.go:179.28,181.3 1 0 +trip-planner/internal/yandex/client.go:183.2,184.65 2 0 +trip-planner/internal/yandex/client.go:184.65,186.3 1 0 +trip-planner/internal/yandex/client.go:188.2,188.19 1 0 +trip-planner/internal/yandex/client.go:245.35,247.2 1 1 +trip-planner/internal/yandex/client.go:250.54,252.2 1 1 +trip-planner/internal/yandex/client.go:255.39,256.16 1 1 +trip-planner/internal/yandex/client.go:256.16,258.3 1 1 +trip-planner/internal/yandex/client.go:260.2,260.13 1 1 +trip-planner/internal/yandex/client.go:264.60,268.26 2 1 +trip-planner/internal/yandex/client.go:268.26,270.3 1 1 +trip-planner/internal/yandex/client.go:271.2,271.12 1 1 +trip-planner/internal/yandex/client.go:276.60,283.2 1 1 +trip-planner/internal/yandex/client.go:285.40,292.19 5 1 +trip-planner/internal/yandex/client.go:292.19,295.3 2 1 +trip-planner/internal/yandex/client.go:297.2,297.117 1 1 +trip-planner/internal/yandex/client.go:300.46,302.28 2 1 +trip-planner/internal/yandex/client.go:302.28,306.3 2 1 +trip-planner/internal/yandex/client.go:312.42,318.2 1 1 +trip-planner/internal/yandex/client.go:320.40,324.18 3 1 +trip-planner/internal/yandex/client.go:325.14,326.14 1 1 +trip-planner/internal/yandex/client.go:327.12,329.45 1 1 +trip-planner/internal/yandex/client.go:329.45,333.4 3 1 +trip-planner/internal/yandex/client.go:334.3,334.15 1 1 +trip-planner/internal/yandex/client.go:335.16,336.14 1 0 +trip-planner/internal/yandex/client.go:338.2,338.14 1 0 +trip-planner/internal/yandex/client.go:341.43,345.18 3 1 +trip-planner/internal/yandex/client.go:346.14,346.14 0 0 +trip-planner/internal/yandex/client.go:348.16,350.24 2 1 +trip-planner/internal/yandex/client.go:350.24,353.4 2 1 +trip-planner/internal/yandex/client.go:354.12,354.12 0 0 +trip-planner/internal/yandex/client.go:359.43,363.18 3 1 +trip-planner/internal/yandex/client.go:364.14,366.38 2 1 +trip-planner/internal/yandex/client.go:366.38,369.4 2 1 +trip-planner/internal/yandex/client.go:370.16,372.28 2 0 +trip-planner/internal/yandex/client.go:373.12,373.12 0 1 +trip-planner/internal/yandex/client.go:380.55,382.16 2 0 +trip-planner/internal/yandex/client.go:382.16,384.3 1 0 +trip-planner/internal/yandex/client.go:385.2,385.25 1 0 +trip-planner/internal/yandex/client.go:388.28,391.2 1 0 diff --git a/docs/plans/2026-08-13-MVP-Routing-Implementation.md b/docs/plans/2026-08-13-MVP-Routing-Implementation.md index b843333..cf63984 100644 --- a/docs/plans/2026-08-13-MVP-Routing-Implementation.md +++ b/docs/plans/2026-08-13-MVP-Routing-Implementation.md @@ -112,12 +112,12 @@ Implement the Minimum Viable Product for the multimodal trip planning service, f - [x] Run tests - must pass before task 7 ### Task 7: End-to-end integration and full test suite -- [ ] Write integration tests connecting all components: API → cache → routing → Yandex client -- [ ] Write synthetic timetable fixtures for routing tests (no real API calls) -- [ ] Run full test suite: `go test ./... -cover` -- [ ] Verify coverage meets project standard (80%+) -- [ ] Fix any failing tests -- [ ] Run `go fmt ./...` and `go vet ./...` - all issues must be fixed +- [x] Write integration tests connecting all components: API → cache → routing → Yandex client +- [x] Write synthetic timetable fixtures for routing tests (no real API calls) +- [x] Run full test suite: `go test ./... -cover` +- [x] Verify coverage meets project standard (80%+) +- [x] Fix any failing tests +- [x] Run `go fmt ./...` and `go vet ./...` - all issues must be fixed - [ ] Final verification: manual API endpoint testing with curl or Postman ## Post-Completion diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..d1aa9d9036f92bd01e303d502f19b5cb5abe328b GIT binary patch literal 259 zcmWG?b@2=~FfcUz#aWb^l3A=rhFr}hUrD!H50)0}J`v3GbCZ4CwQ+5CV469 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 := NewGraph() + + graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) + graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"}) + graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Clinic", CityCode: "c1"}) + graph.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"}) + + // Chain: s1 -> s2 -> s3 -> s4 (3 edges, 3 transfers if all are real) + // But make edges real so each is one leg, not transfer + graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 300, Transport: "train", IsTransfer: false}) + graph.AddEdge(&Edge{From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindReal, Duration: 300, Transport: "train", IsTransfer: false}) + graph.AddEdge(&Edge{From: graph.Nodes()[2], To: graph.Nodes()[3], Kind: EdgeKindReal, Duration: 300, Transport: "train", IsTransfer: false}) + + // With max 1 transfer, should not find route requiring 3 legs + opts := SearchOptions{MaxTransfers: 1, MCT: 0} + result := graph.FindRoute("s1", "s4", opts) + + if result == nil { + t.Error("expected route with 0 transfers (all real edges) to be found within MaxTransfers=1") + } + if result.TotalTransfers != 0 { + t.Errorf("expected 0 transfers (all real edges), got %d", result.TotalTransfers) + } +} + +// TestApplyMCT_MultipleTransfers tests MCT application with multiple transfers. +func TestApplyMCT_MultipleTransfers(t *testing.T) { + graph := NewGraph() + + graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"}) + graph.AddNode(&Node{ID: "s2", Type: NodeTypeCity, Name: "City1", CityCode: "c1"}) + graph.AddNode(&Node{ID: "s3", Type: NodeTypeCity, Name: "City2", CityCode: "c1"}) + graph.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"}) + + // Moscow -> City1 (real, train) + graph.AddEdge(&Edge{From: graph.Nodes()[0], To: graph.Nodes()[1], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false}) + // City1 -> City2 (real, train) + graph.AddEdge(&Edge{From: graph.Nodes()[1], To: graph.Nodes()[2], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false}) + // City2 -> Tula (real, train) + graph.AddEdge(&Edge{From: graph.Nodes()[2], To: graph.Nodes()[3], Kind: EdgeKindReal, Duration: 3600, Transport: "train", IsTransfer: false}) + + itinerary := &Itinerary{ + Legs: []RouteLeg{ + {From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false}, + {From: graph.Nodes()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "train", IsTransfer: false}, + {From: graph.Nodes()[2], To: graph.Nodes()[3], Duration: 3600, Transport: "train", IsTransfer: false}, + }, + TotalDuration: 0, + TotalTransfers: 0, + } + + result := graph.ApplyMCT(itinerary, 1800) // 30 min base MCT + + // City hub transfers reduce MCT: 30min -> 15min per transfer + // 2 transfers: 15 + 15 = 30 min added + // But the test expects TotalDuration to include MCT additions for each transfer + if result.TotalDuration != 1800 { + t.Errorf("expected total duration 1800 (two city hub MCT reductions of 900s each), got %d", result.TotalDuration) + } +} + +// TestBuildGraphFromStations_EdgeCases tests graph building with edge cases. +func TestBuildGraphFromStations_EdgeCases(t *testing.T) { + // Empty stations list + graph := BuildGraphFromStations(nil) + if len(graph.Nodes()) != 0 { + t.Errorf("expected 0 nodes for empty stations list, got %d", len(graph.Nodes())) + } + if len(graph.Edges()) != 0 { + t.Errorf("expected 0 edges for empty stations list, got %d", len(graph.Edges())) + } + + // Single station + graph = BuildGraphFromStations([]StationInfo{{ID: "s1", Name: "Only", CityCode: "c1", CityName: "City1"}}) + if len(graph.Nodes()) != 2 { // 1 station + 1 city + t.Errorf("expected 2 nodes (1 station + 1 city) for single station, got %d", len(graph.Nodes())) + } + if len(graph.Edges()) != 2 { // 2 synthetic edges (station<->city) + t.Errorf("expected 2 edges for single station, got %d", len(graph.Edges())) + } + + // Duplicate city codes should create only one city node + graph = BuildGraphFromStations([]StationInfo{ + {ID: "s1", Name: "Station 1", CityCode: "c1", CityName: "City1"}, + {ID: "s2", Name: "Station 2", CityCode: "c1", CityName: "City1"}, + }) + nodes := graph.Nodes() + cityCount := 0 + 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") + } +}