Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 94 additions & 24 deletions OV_DB.Tests/StationSuggestionServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ private static Station StationRow(int id, string name) =>

private static User TestUser() => new() { Id = 7 };

/// <summary>The ordinary case: Träwelling has budget left, so the call is worth making.</summary>
private static ITraewellingRateLimiter NotLimited()
{
var limiter = new Mock<ITraewellingRateLimiter>();
limiter.SetupGet(l => l.IsLimited).Returns(false);
return limiter.Object;
}

private static ITraewellingRateLimiter Limited()
{
var limiter = new Mock<ITraewellingRateLimiter>();
limiter.SetupGet(l => l.IsLimited).Returns(true);
return limiter.Object;
}

/// <summary>A three-station trip, boarded at the first station and left at the last.</summary>
private static string StatusPayload(int tripId = TripId, int originId = 1, int destinationId = 3) => $$"""
{
Expand All @@ -84,7 +99,7 @@ public async Task SuggestsTheStationsTheOperatorSaysTheTrainCalledAt()
var service = new StationSuggestionService(
context,
MatcherFor(Candidate(1, "Karlsruhe Hbf", 12), Candidate(2, "Pforzheim Hbf", 30)).Object,
new Mock<ITrawellingService>().Object);
new Mock<ITrawellingService>().Object, NotLimited());

var suggestions = await service.FromStopsAsync(7, [Stop("Karlsruhe Hbf"), Stop("Pforzheim Hbf")]);

Expand All @@ -103,10 +118,11 @@ public async Task ReadsTheTripOutOfThePayloadTheImportAlreadyHas()
.ReturnsAsync([Stopover(1, "Origin"), Stopover(3, "Destination")]);

var service = new StationSuggestionService(
context, MatcherFor(Candidate(1, "Origin")).Object, trawelling.Object);
var suggestions = await service.FromTrawellingStatusAsync(TestUser(), StatusPayload());
context, MatcherFor(Candidate(1, "Origin")).Object, trawelling.Object, NotLimited());
var result = await service.FromTrawellingStatusAsync(TestUser(), StatusPayload());

Assert.Equal([1], suggestions.Select(s => s.StationId));
Assert.Equal([1], result.Stations.Select(s => s.StationId));
Assert.False(result.Unavailable);
// One call, because the trip id was already in the stored payload.
trawelling.Verify(t => t.GetTripStopoversAsync(It.IsAny<User>(), TripId, It.IsAny<CancellationToken>()), Times.Once);
}
Expand All @@ -121,7 +137,7 @@ public async Task TheEndsOfTheJourneyAreFlaggedAndTheMiddleIsNot()
var service = new StationSuggestionService(
context,
MatcherFor(Candidate(1, "A"), Candidate(2, "B"), Candidate(3, "C")).Object,
new Mock<ITrawellingService>().Object);
new Mock<ITrawellingService>().Object, NotLimited());

var suggestions = await service.FromStopsAsync(7, [Stop("A"), Stop("B"), Stop("C")]);

Expand All @@ -144,7 +160,7 @@ public async Task AnEndAlreadyMarkedLeavesTheStationsBehindItUnflagged()
var service = new StationSuggestionService(
context,
MatcherFor(Candidate(1, "A"), Candidate(2, "B"), Candidate(3, "C")).Object,
new Mock<ITrawellingService>().Object);
new Mock<ITrawellingService>().Object, NotLimited());

var suggestions = await service.FromStopsAsync(7, [Stop("A"), Stop("B"), Stop("C")]);

Expand All @@ -161,7 +177,7 @@ public async Task ASingleStopIsNotAnEndpoint()
await context.SaveChangesAsync();

var service = new StationSuggestionService(
context, MatcherFor(Candidate(1, "A")).Object, new Mock<ITrawellingService>().Object);
context, MatcherFor(Candidate(1, "A")).Object, new Mock<ITrawellingService>().Object, NotLimited());

var suggestions = await service.FromStopsAsync(7, [Stop("A")]);

Expand All @@ -188,10 +204,10 @@ public async Task OnlyTheSectionRiddenIsSuggested()
MatcherFor(
Candidate(1, "Before"), Candidate(2, "Origin"), Candidate(3, "Middle"),
Candidate(4, "Destination"), Candidate(5, "Beyond")).Object,
trawelling.Object);
trawelling.Object, NotLimited());

var suggestions = await service.FromTrawellingStatusAsync(
TestUser(), StatusPayload(originId: 20, destinationId: 40));
var suggestions = (await service.FromTrawellingStatusAsync(
TestUser(), StatusPayload(originId: 20, destinationId: 40))).Stations;

// The train ran on past where the user got off, and had been running before they boarded.
// Those stations were never reached.
Expand All @@ -215,10 +231,10 @@ public async Task AStationCalledAtTwiceTakesTheArrivalAfterBoarding()
var service = new StationSuggestionService(
context,
MatcherFor(Candidate(1, "Loop"), Candidate(2, "Origin"), Candidate(3, "Far")).Object,
trawelling.Object);
trawelling.Object, NotLimited());

var suggestions = await service.FromTrawellingStatusAsync(
TestUser(), StatusPayload(originId: 20, destinationId: 10));
var suggestions = (await service.FromTrawellingStatusAsync(
TestUser(), StatusPayload(originId: 20, destinationId: 10))).Stations;

// Boarded at Origin and left at the second call at Loop, so Far is on the way and the first
// call at Loop is not.
Expand All @@ -237,11 +253,11 @@ public async Task TheWholePatternIsKeptWhenTheEndsAreNotInIt()
.ReturnsAsync([Stopover(10, "A"), Stopover(20, "B")]);

var service = new StationSuggestionService(
context, MatcherFor(Candidate(1, "A"), Candidate(2, "B")).Object, trawelling.Object);
context, MatcherFor(Candidate(1, "A"), Candidate(2, "B")).Object, trawelling.Object, NotLimited());

// Neither the origin nor the destination appears among the stopovers.
var suggestions = await service.FromTrawellingStatusAsync(
TestUser(), StatusPayload(originId: 999, destinationId: 998));
var suggestions = (await service.FromTrawellingStatusAsync(
TestUser(), StatusPayload(originId: 999, destinationId: 998))).Stations;

// Over-offering is recoverable - the user says no. Dropping stations they did visit is not.
Assert.Equal([1, 2], suggestions.Select(s => s.StationId));
Expand Down Expand Up @@ -272,7 +288,7 @@ public async Task OnlyTheStopsWithCoordinatesReachTheMatcher()
list.Where(s => s.Name == "Origin").Select(s => Candidate(1, "Origin")).ToList());
});

var service = new StationSuggestionService(context, matcher.Object, trawelling.Object);
var service = new StationSuggestionService(context, matcher.Object, trawelling.Object, NotLimited());
await service.FromTrawellingStatusAsync(TestUser(), StatusPayload());

Assert.Equal(["Origin"], seen[0].Select(s => s.Name));
Expand All @@ -289,7 +305,7 @@ public async Task AStationAlreadyMarkedIsNotSuggestedAgain()
var service = new StationSuggestionService(
context,
MatcherFor(Candidate(1, "Karlsruhe Hbf"), Candidate(2, "Pforzheim Hbf")).Object,
new Mock<ITrawellingService>().Object);
new Mock<ITrawellingService>().Object, NotLimited());

var suggestions = await service.FromStopsAsync(7, [Stop("Karlsruhe Hbf"), Stop("Pforzheim Hbf")]);

Expand All @@ -312,7 +328,7 @@ public async Task AStationTheUserWavedAwayIsNotSuggestedAgain()
var service = new StationSuggestionService(
context,
MatcherFor(Candidate(1, "Karlsruhe Hbf"), Candidate(2, "Pforzheim Hbf")).Object,
new Mock<ITrawellingService>().Object);
new Mock<ITrawellingService>().Object, NotLimited());

var suggestions = await service.FromStopsAsync(7, [Stop("Karlsruhe Hbf"), Stop("Pforzheim Hbf")]);

Expand All @@ -333,7 +349,7 @@ public async Task HiddenAndSpecialStationsAreNeverSuggested()
var service = new StationSuggestionService(
context,
MatcherFor(Candidate(1, "Depot"), Candidate(2, "Marker"), Candidate(3, "Pforzheim Hbf")).Object,
new Mock<ITrawellingService>().Object);
new Mock<ITrawellingService>().Object, NotLimited());

var suggestions = await service.FromStopsAsync(
7, [Stop("Depot"), Stop("Marker"), Stop("Pforzheim Hbf")]);
Expand All @@ -352,9 +368,9 @@ public async Task WithoutATripThereIsNothingToAsk(string payload)
// Check-ins with no trip behind them happen: manual entries, older payloads. Suggestions sit
// on top of an import that has already succeeded, so this has to be quiet, not an error.
var trawelling = new Mock<ITrawellingService>();
var service = new StationSuggestionService(NewContext(), MatcherFor().Object, trawelling.Object);
var service = new StationSuggestionService(NewContext(), MatcherFor().Object, trawelling.Object, NotLimited());

Assert.Empty(await service.FromTrawellingStatusAsync(TestUser(), payload));
Assert.Empty((await service.FromTrawellingStatusAsync(TestUser(), payload)).Stations);
trawelling.Verify(
t => t.GetTripStopoversAsync(It.IsAny<User>(), It.IsAny<int>(), It.IsAny<CancellationToken>()),
Times.Never);
Expand All @@ -369,8 +385,62 @@ public async Task ATripWithNoCallingPatternSuggestsNothing()
trawelling.Setup(t => t.GetTripStopoversAsync(It.IsAny<User>(), TripId, It.IsAny<CancellationToken>()))
.ReturnsAsync([]);

var service = new StationSuggestionService(NewContext(), MatcherFor().Object, trawelling.Object);
var service = new StationSuggestionService(NewContext(), MatcherFor().Object, trawelling.Object, NotLimited());

Assert.Empty((await service.FromTrawellingStatusAsync(TestUser(), StatusPayload())).Stations);
}

[Fact]
public async Task ARateLimitedTraewellingIsNotWaitedOnAtAll()
{
// The trip is already saved by the time this runs, and the limiter sleeps out its whole
// window. Waiting a minute to find out what we already know is the worst of both.
var trawelling = new Mock<ITrawellingService>();
var service = new StationSuggestionService(
NewContext(), MatcherFor().Object, trawelling.Object, Limited());

var result = await service.FromTrawellingStatusAsync(TestUser(), StatusPayload());

Assert.True(result.Unavailable);
Assert.Empty(result.Stations);
trawelling.Verify(
t => t.GetTripStopoversAsync(It.IsAny<User>(), It.IsAny<int>(), It.IsAny<CancellationToken>()),
Times.Never);
}

[Fact]
public async Task AFetchThatOutlastsItsWelcomeIsReportedRatherThanWaitedOut()
{
// Cancelled from inside, which is what the timeout does. The caller's own token is still
// live, so this is the service giving up rather than the request going away.
var trawelling = new Mock<ITrawellingService>();
trawelling.Setup(t => t.GetTripStopoversAsync(It.IsAny<User>(), TripId, It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());

var service = new StationSuggestionService(
NewContext(), MatcherFor().Object, trawelling.Object, NotLimited());

var result = await service.FromTrawellingStatusAsync(TestUser(), StatusPayload());

Assert.True(result.Unavailable);
Assert.Empty(result.Stations);
}

[Fact]
public async Task ACallerWhoGaveUpIsNotToldTheTripHasNoPattern()
{
// The request itself was aborted, so there is nobody to tell and nothing to report. That
// cancellation belongs to the caller and is passed back rather than swallowed as a result.
var trawelling = new Mock<ITrawellingService>();
trawelling.Setup(t => t.GetTripStopoversAsync(It.IsAny<User>(), TripId, It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());

var service = new StationSuggestionService(
NewContext(), MatcherFor().Object, trawelling.Object, NotLimited());
using var aborted = new CancellationTokenSource();
await aborted.CancelAsync();

Assert.Empty(await service.FromTrawellingStatusAsync(TestUser(), StatusPayload()));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.FromTrawellingStatusAsync(TestUser(), StatusPayload(), aborted.Token));
}
}
104 changes: 97 additions & 7 deletions OV_DB.Tests/StationTripMatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ public async Task ARouteAddedAfterTheIndexWasBuiltIsStillFound()

var candidates = await matcher.FindTripsForStationAsync(1, 10);

Assert.Equal(2, cache.Builds);
Assert.Equal(2, cache.RouteBuilds);
Assert.Contains(90, candidates.Select(c => c.RouteInstanceId));
}

Expand Down Expand Up @@ -576,7 +576,86 @@ public async Task TheIndexIsBuiltOnceAndSharedAcrossCalls()
await matcher.FindStationsForTripAsync(1, 1);
await matcher.FindTripsForStationAsync(1, 10);

Assert.Equal(1, cache.Builds);
Assert.Equal(1, cache.RouteBuilds);
Assert.Equal(1, cache.StationBuilds);
}

[Fact]
public async Task WarmingBuildsTheRouteIndexUpFrontAndOnlyOnce()
{
// What the backfill page asks for on the way in, so the first station does not sit through
// the build with nothing to show for it.
using var context = await SeedAsync();
await AddStationAsync(context, 10, "Halt", BaseLat, BaseLon);
var cache = new CountingCache();
var matcher = new StationTripMatcher(context, cache);

Assert.False(await matcher.IsRouteIndexWarmAsync());

await matcher.WarmRouteIndexAsync();

Assert.True(await matcher.IsRouteIndexWarmAsync());
Assert.Equal(1, cache.RouteBuilds);

// And the work it was warmed for now costs nothing more.
Assert.Single(await matcher.FindTripsForStationAsync(1, 10));
Assert.Equal(1, cache.RouteBuilds);
}

[Fact]
public async Task WarmingReportsEveryRouteRowItPassed()
{
using var context = await SeedAsync();
var reports = new List<MatcherWarmupProgress>();
var matcher = NewMatcher(context);

await matcher.WarmRouteIndexAsync(new CollectingProgress(reports.Add));

// One route in the seed, so the bar has somewhere to end up rather than a total of zero.
Assert.NotEmpty(reports);
Assert.Equal(1, reports[^1].Processed);
Assert.Equal(1, reports[^1].Total);
}

private sealed class CollectingProgress(Action<MatcherWarmupProgress> report) : IProgress<MatcherWarmupProgress>
{
public void Report(MatcherWarmupProgress value) => report(value);
}

[Fact]
public async Task MatchingStopsNeverBuildsTheRouteIndex()
{
// The import path. Route geometry is hundreds of megabytes and a calling pattern says
// nothing about it, so a trip on a freshly imported line must not pay to rebuild it - which
// is exactly what a shared fingerprint made every Traewelling import do.
using var context = await SeedAsync();
await AddStationAsync(context, 10, "Halt", BaseLat, BaseLon);
var cache = new CountingCache();
var matcher = new StationTripMatcher(context, cache);

var matched = await matcher.MatchStopsAsync([new StopPoint("Halt", BaseLat, BaseLon)]);

Assert.Equal(10, Assert.Single(matched).StationId);
Assert.Equal(0, cache.RouteBuilds);
Assert.Equal(1, cache.StationBuilds);
}

[Fact]
public async Task AStationAddedAfterTheIndexWasBuiltIsStillMatched()
{
// The station half of the same guarantee: the counts have to catch a new station on their
// own now that stations no longer ride along on the route fingerprint.
using var context = await SeedAsync();
var cache = new CountingCache();
var matcher = new StationTripMatcher(context, cache);
Assert.Empty(await matcher.MatchStopsAsync([new StopPoint("Halt", BaseLat, BaseLon)]));

await AddStationAsync(context, 10, "Halt", BaseLat, BaseLon);

var matched = await matcher.MatchStopsAsync([new StopPoint("Halt", BaseLat, BaseLon)]);

Assert.Equal(10, Assert.Single(matched).StationId);
Assert.Equal(2, cache.StationBuilds);
}

[Fact]
Expand All @@ -591,21 +670,32 @@ public async Task InvalidatingForcesTheIndexToBeRebuilt()
cache.Invalidate();
await matcher.FindTripsForStationAsync(1, 10);

Assert.Equal(2, cache.Builds);
Assert.Equal(2, cache.RouteBuilds);
}

private sealed class CountingCache : IMatcherIndexCache
{
private readonly MatcherIndexCache _inner = new();
public int Builds { get; private set; }
public int RouteBuilds { get; private set; }
public int StationBuilds { get; private set; }
public int Builds => RouteBuilds + StationBuilds;

public Task<RouteIndex> GetRoutesAsync(int routeCount, Func<CancellationToken, Task<RouteIndex>> build, CancellationToken cancellationToken = default) =>
_inner.GetRoutesAsync(routeCount, ct =>
{
RouteBuilds++;
return build(ct);
}, cancellationToken);

public Task<MatcherIndex> GetAsync(int routeCount, int stationCount, Func<CancellationToken, Task<MatcherIndex>> build, CancellationToken cancellationToken = default) =>
_inner.GetAsync(routeCount, stationCount, ct =>
public Task<StationIndex> GetStationsAsync(int stationCount, Func<CancellationToken, Task<StationIndex>> build, CancellationToken cancellationToken = default) =>
_inner.GetStationsAsync(stationCount, ct =>
{
Builds++;
StationBuilds++;
return build(ct);
}, cancellationToken);

public bool HasRoutes(int routeCount) => _inner.HasRoutes(routeCount);

public void Invalidate() => _inner.Invalidate();
}
}
Loading
Loading