From c35fec73eb3a8466a7556a404f89164980cbd824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 Aug 2025 17:33:41 +0000 Subject: [PATCH] Implement enhanced transport connection optimization logic Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> --- Services/TransportConnectionCalculator.cs | 67 ++++++ Services/TransportService.cs | 14 +- Shared/TransportModels.cs | 2 + .../TransportConnectionCalculatorTests.cs | 222 ++++++++++++++++++ .../TransportServiceTests.cs | 86 ++++++- 5 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 ShiftScheduler.Services.Tests/TransportConnectionCalculatorTests.cs diff --git a/Services/TransportConnectionCalculator.cs b/Services/TransportConnectionCalculator.cs index 874cec2..d42bdd6 100644 --- a/Services/TransportConnectionCalculator.cs +++ b/Services/TransportConnectionCalculator.cs @@ -33,4 +33,71 @@ public static class TransportConnectionCalculator .OrderBy(c => DateTime.Parse(c.ArrivalTime ?? "23:59")) .FirstOrDefault(); } + + public static TransportConnection? FindBestConnection( + IReadOnlyList connections, + DateTime shiftStartTime, + int safetyBufferMinutes, + int maxEarlyArrivalMinutes, + int maxLateArrivalMinutes) + { + var latestArrivalTime = shiftStartTime.AddMinutes(-safetyBufferMinutes); + var earliestAcceptableTime = shiftStartTime.AddMinutes(-maxEarlyArrivalMinutes); + var latestAcceptableTime = shiftStartTime.AddMinutes(maxLateArrivalMinutes); + + var validConnections = new List(); + var lateValidConnections = new List(); + + foreach (var connection in connections) + { + if (DateTime.TryParse(connection.ArrivalTime, out var arrivalTime)) + { + if (arrivalTime <= latestArrivalTime) + { + validConnections.Add(connection); + } + else if (arrivalTime <= latestAcceptableTime) + { + lateValidConnections.Add(connection); + } + } + } + + // If we have valid connections (arriving before latest arrival time) + if (validConnections.Count > 0) + { + var sortedValid = validConnections + .OrderBy(c => DateTime.Parse(c.ArrivalTime ?? "00:00")) + .ToList(); + + var bestValidConnection = sortedValid.Last(); + var bestValidArrivalTime = DateTime.Parse(bestValidConnection.ArrivalTime!); + + // Check if the best valid connection arrives too early (more than maxEarlyArrivalMinutes before shift) + if (bestValidArrivalTime < earliestAcceptableTime && lateValidConnections.Count > 0) + { + // Return the earliest connection that arrives after latest arrival time but within acceptable range + var sortedLateValid = lateValidConnections + .OrderBy(c => DateTime.Parse(c.ArrivalTime ?? "23:59")) + .ToList(); + + return sortedLateValid.First(); + } + + return bestValidConnection; + } + + // If no connections arrive before latest arrival time, check if any arrive within acceptable late range + if (lateValidConnections.Count > 0) + { + var sortedLateValid = lateValidConnections + .OrderBy(c => DateTime.Parse(c.ArrivalTime ?? "23:59")) + .ToList(); + + return sortedLateValid.First(); + } + + // No suitable connections found + return null; + } } \ No newline at end of file diff --git a/Services/TransportService.cs b/Services/TransportService.cs index c6f4a54..9715297 100644 --- a/Services/TransportService.cs +++ b/Services/TransportService.cs @@ -9,9 +9,12 @@ namespace ShiftScheduler.Services { var latestArrivalTime = shiftStartTime.AddMinutes(-config.SafetyBufferMinutes); var searchDate = shiftStartTime.ToString("yyyy-MM-dd"); - var searchTime = shiftStartTime.ToString("HH:mm"); + + // To allow connections that arrive after shift starts, we search from earlier time + // and request more connections to cover the full range + var searchTime = shiftStartTime.AddMinutes(-config.MaxEarlyArrivalMinutes - config.SafetyBufferMinutes).ToString("HH:mm"); - var url = $"{config.ApiBaseUrl}/connections?from={Uri.EscapeDataString(config.StartStation)}&to={Uri.EscapeDataString(config.EndStation)}&date={searchDate}&time={searchTime}&isArrivalTime=1&limit=5"; + var url = $"{config.ApiBaseUrl}/connections?from={Uri.EscapeDataString(config.StartStation)}&to={Uri.EscapeDataString(config.EndStation)}&date={searchDate}&time={searchTime}&isArrivalTime=1&limit=10"; var response = await httpClient.GetStringAsync(url); var apiResponse = JsonSerializer.Deserialize(response, new JsonSerializerOptions { @@ -21,7 +24,12 @@ namespace ShiftScheduler.Services if (apiResponse?.Connections.Count > 0) { var allConnections = apiResponse.Connections.Select(MapToTransportConnection).ToList(); - return TransportConnectionCalculator.FindBestConnection(allConnections, latestArrivalTime); + return TransportConnectionCalculator.FindBestConnection( + allConnections, + shiftStartTime, + config.SafetyBufferMinutes, + config.MaxEarlyArrivalMinutes, + config.MaxLateArrivalMinutes); } return null; diff --git a/Shared/TransportModels.cs b/Shared/TransportModels.cs index aa7de4d..e374a26 100644 --- a/Shared/TransportModels.cs +++ b/Shared/TransportModels.cs @@ -7,6 +7,8 @@ namespace ShiftScheduler.Shared public string ApiBaseUrl { get; set; } = string.Empty; public int SafetyBufferMinutes { get; set; } = 30; public int MinBreakMinutes { get; set; } = 60; + public int MaxEarlyArrivalMinutes { get; set; } = 60; // Y parameter + public int MaxLateArrivalMinutes { get; set; } = 15; // X parameter } public class TransportConnection diff --git a/ShiftScheduler.Services.Tests/TransportConnectionCalculatorTests.cs b/ShiftScheduler.Services.Tests/TransportConnectionCalculatorTests.cs new file mode 100644 index 0000000..7b34d1e --- /dev/null +++ b/ShiftScheduler.Services.Tests/TransportConnectionCalculatorTests.cs @@ -0,0 +1,222 @@ +using ShiftScheduler.Services; +using ShiftScheduler.Shared; +using Shouldly; + +namespace ShiftScheduler.Services.Tests; + +public class TransportConnectionCalculatorTests +{ + [Fact] + public void FindBestConnection_WithValidConnections_ShouldReturnLatestValidConnection() + { + // Arrange + var connections = new List + { + new TransportConnection { ArrivalTime = "2023-12-15T07:15:00" }, + new TransportConnection { ArrivalTime = "2023-12-15T07:25:00" }, + new TransportConnection { ArrivalTime = "2023-12-15T07:35:00" } // This one is too late + }; + var latestArrivalTime = new DateTime(2023, 12, 15, 7, 30, 0); + + // Act + var result = TransportConnectionCalculator.FindBestConnection(connections, latestArrivalTime); + + // Assert + result.ShouldNotBeNull(); + result.ArrivalTime.ShouldBe("2023-12-15T07:25:00"); + } + + [Fact] + public void FindBestConnection_WithNoValidConnections_ShouldReturnEarliestConnection() + { + // Arrange + var connections = new List + { + new TransportConnection { ArrivalTime = "2023-12-15T07:35:00" }, + new TransportConnection { ArrivalTime = "2023-12-15T07:40:00" }, + new TransportConnection { ArrivalTime = "2023-12-15T07:32:00" } + }; + var latestArrivalTime = new DateTime(2023, 12, 15, 7, 30, 0); + + // Act + var result = TransportConnectionCalculator.FindBestConnection(connections, latestArrivalTime); + + // Assert + result.ShouldNotBeNull(); + result.ArrivalTime.ShouldBe("2023-12-15T07:32:00"); // Earliest of the invalid ones + } + + [Fact] + public void FindBestConnection_WithEmptyConnections_ShouldReturnNull() + { + // Arrange + var connections = new List(); + var latestArrivalTime = new DateTime(2023, 12, 15, 7, 30, 0); + + // Act + var result = TransportConnectionCalculator.FindBestConnection(connections, latestArrivalTime); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public void FindBestConnection_WithInvalidArrivalTimes_ShouldIgnoreInvalidEntries() + { + // Arrange + var connections = new List + { + new TransportConnection { ArrivalTime = "invalid-time" }, + new TransportConnection { ArrivalTime = "2023-12-15T07:25:00" }, + new TransportConnection { ArrivalTime = "" } + }; + var latestArrivalTime = new DateTime(2023, 12, 15, 7, 30, 0); + + // Act + var result = TransportConnectionCalculator.FindBestConnection(connections, latestArrivalTime); + + // Assert + result.ShouldNotBeNull(); + result.ArrivalTime.ShouldBe("2023-12-15T07:25:00"); + } + + // Tests for new enhanced logic + [Fact] + public void FindBestConnectionEnhanced_WithGoodTimingConnection_ShouldReturnLatestValidConnection() + { + // Arrange - Shift starts at 8:00, safety buffer 30 min, so latest arrival is 7:30 + var connections = new List + { + new TransportConnection { ArrivalTime = "2023-12-15T07:15:00" }, + new TransportConnection { ArrivalTime = "2023-12-15T07:25:00" }, // Should be selected (latest valid) + new TransportConnection { ArrivalTime = "2023-12-15T08:05:00" } // Too late + }; + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + + // Act + var result = TransportConnectionCalculator.FindBestConnection( + connections, shiftStartTime, 30, 60, 15); + + // Assert + result.ShouldNotBeNull(); + result.ArrivalTime.ShouldBe("2023-12-15T07:25:00"); + } + + [Fact] + public void FindBestConnectionEnhanced_WithTooEarlyConnection_ShouldPreferLaterConnection() + { + // Arrange - Shift starts at 8:00, max early arrival 60 min, so earliest acceptable is 7:00 + var connections = new List + { + new TransportConnection { ArrivalTime = "2023-12-15T06:45:00" }, // Too early (more than 60 min before 8:00) + new TransportConnection { ArrivalTime = "2023-12-15T08:05:00" } // Within acceptable late range (15 min after 8:00) + }; + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + + // Act + var result = TransportConnectionCalculator.FindBestConnection( + connections, shiftStartTime, 30, 60, 15); + + // Assert + result.ShouldNotBeNull(); + result.ArrivalTime.ShouldBe("2023-12-15T08:05:00"); + } + + [Fact] + public void FindBestConnectionEnhanced_WithMultipleLateConnections_ShouldReturnEarliest() + { + // Arrange - Shift starts at 8:00, no valid early connections + var connections = new List + { + new TransportConnection { ArrivalTime = "2023-12-15T08:10:00" }, + new TransportConnection { ArrivalTime = "2023-12-15T08:05:00" }, // Should be selected (earliest late) + new TransportConnection { ArrivalTime = "2023-12-15T08:12:00" } + }; + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + + // Act + var result = TransportConnectionCalculator.FindBestConnection( + connections, shiftStartTime, 30, 60, 15); + + // Assert + result.ShouldNotBeNull(); + result.ArrivalTime.ShouldBe("2023-12-15T08:05:00"); + } + + [Fact] + public void FindBestConnectionEnhanced_WithConnectionsBeyondAcceptableRange_ShouldReturnNull() + { + // Arrange - All connections are too late (beyond max late arrival) + var connections = new List + { + new TransportConnection { ArrivalTime = "2023-12-15T08:20:00" }, + new TransportConnection { ArrivalTime = "2023-12-15T08:25:00" }, + new TransportConnection { ArrivalTime = "2023-12-15T08:30:00" } + }; + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + + // Act + var result = TransportConnectionCalculator.FindBestConnection( + connections, shiftStartTime, 30, 60, 15); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public void FindBestConnectionEnhanced_WithAcceptableEarlyConnection_ShouldNotPreferLate() + { + // Arrange - Early connection is acceptable (not too early), should prefer it over late + var connections = new List + { + new TransportConnection { ArrivalTime = "2023-12-15T07:20:00" }, // 40 min before shift - acceptable + new TransportConnection { ArrivalTime = "2023-12-15T08:05:00" } // 5 min after shift + }; + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + + // Act + var result = TransportConnectionCalculator.FindBestConnection( + connections, shiftStartTime, 30, 60, 15); + + // Assert + result.ShouldNotBeNull(); + result.ArrivalTime.ShouldBe("2023-12-15T07:20:00"); + } + + [Fact] + public void FindBestConnectionEnhanced_WithEmptyConnections_ShouldReturnNull() + { + // Arrange + var connections = new List(); + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + + // Act + var result = TransportConnectionCalculator.FindBestConnection( + connections, shiftStartTime, 30, 60, 15); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public void FindBestConnectionEnhanced_WithMixedValidAndLateConnections_ShouldChooseBasedOnTiming() + { + // Arrange - Test scenario where both valid and late connections exist + var connections = new List + { + new TransportConnection { ArrivalTime = "2023-12-15T06:30:00" }, // Too early (90 min before shift) + new TransportConnection { ArrivalTime = "2023-12-15T07:25:00" }, // Valid but would be considered too early + new TransportConnection { ArrivalTime = "2023-12-15T08:05:00" }, // Late but acceptable + new TransportConnection { ArrivalTime = "2023-12-15T08:20:00" } // Too late + }; + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + + // Act - MaxEarlyArrivalMinutes = 30, so earliest acceptable is 7:30 + var result = TransportConnectionCalculator.FindBestConnection( + connections, shiftStartTime, 30, 30, 15); + + // Assert - 7:25 arrives at 7:25, which is 35 min before shift (more than 30 min), so should prefer late connection + result.ShouldNotBeNull(); + result.ArrivalTime.ShouldBe("2023-12-15T08:05:00"); + } +} \ No newline at end of file diff --git a/ShiftScheduler.Services.Tests/TransportServiceTests.cs b/ShiftScheduler.Services.Tests/TransportServiceTests.cs index 005ecdc..a4a77f6 100644 --- a/ShiftScheduler.Services.Tests/TransportServiceTests.cs +++ b/ShiftScheduler.Services.Tests/TransportServiceTests.cs @@ -25,7 +25,9 @@ public class TransportServiceTests EndStation = "Bern", ApiBaseUrl = "https://transport.opendata.ch/v1", SafetyBufferMinutes = 30, - MinBreakMinutes = 60 + MinBreakMinutes = 60, + MaxEarlyArrivalMinutes = 60, + MaxLateArrivalMinutes = 15 }; _transportService = new TransportService(_httpClient, config); @@ -238,4 +240,86 @@ public class TransportServiceTests } }; } + + [Fact] + public async Task GetConnectionAsync_WithNewLogic_ShouldHandleEarlyAndLateConnections() + { + // Arrange - Shift starts at 8:00, test new logic with late-arriving connections + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + var apiResponse = CreateConnectionsForNewLogicTest(); + var jsonResponse = JsonSerializer.Serialize(apiResponse); + + SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse); + + // Act + var result = await _transportService.GetConnectionAsync(shiftStartTime); + + // Assert + result.ShouldNotBeNull(); + // With MaxEarlyArrivalMinutes = 60, connections arriving before 7:00 are too early + // The connection arriving at 6:30 (90 min before shift) should be considered too early + // So it should prefer the connection at 8:05 (5 min after shift, within MaxLateArrivalMinutes = 15) + result.ArrivalTime.ShouldBe("2023-12-15T08:05:00"); + } + + private static TransportApiResponse CreateConnectionsForNewLogicTest() + { + return new TransportApiResponse + { + Connections = new List + { + // Too early connection (90 min before shift start) + new TransportApiConnection + { + From = new TransportApiCheckpoint + { + Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" }, + Departure = "2023-12-15T05:30:00", + Platform = "5" + }, + To = new TransportApiCheckpoint + { + Station = new TransportApiStation { Name = "Bern", Id = "bern" }, + Arrival = "2023-12-15T06:30:00", + Platform = "3" + }, + Duration = "01:00:00" + }, + // Late but acceptable connection (5 min after shift start) + new TransportApiConnection + { + From = new TransportApiCheckpoint + { + Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" }, + Departure = "2023-12-15T07:20:00", + Platform = "4" + }, + To = new TransportApiCheckpoint + { + Station = new TransportApiStation { Name = "Bern", Id = "bern" }, + Arrival = "2023-12-15T08:05:00", + Platform = "2" + }, + Duration = "00:45:00" + }, + // Too late connection (20 min after shift start, beyond MaxLateArrivalMinutes) + new TransportApiConnection + { + From = new TransportApiCheckpoint + { + Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" }, + Departure = "2023-12-15T07:35:00", + Platform = "6" + }, + To = new TransportApiCheckpoint + { + Station = new TransportApiStation { Name = "Bern", Id = "bern" }, + Arrival = "2023-12-15T08:20:00", + Platform = "1" + }, + Duration = "00:45:00" + } + } + }; + } } \ No newline at end of file