Skip to content
Closed
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
111 changes: 111 additions & 0 deletions WheelWizard.Test/Features/Ghosts/GhostTrackServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Net;
using System.Net.Http;
using System.Text;
using System.Collections.Concurrent;
using WheelWizard.Services;

namespace WheelWizard.Test.Features.Ghosts;

public class GhostTrackServiceTests
{
[Fact]
public async Task GetAllTracksAsync_LoadsTracksWithoutEagerHashResolution_AndCachesResults()
{
var handler = new StubHttpMessageHandler(new Dictionary<string, string>
{
["https://rwfc.net/api/timetrial/tracks"] =
"""
[
{ "id": 1, "name": "Main Track", "courseId": 300, "category": "retro", "laps": 3, "supportsGlitch": true, "sortOrder": 1 },
{ "id": 2, "name": "Variant Track", "courseId": 300, "category": "retro", "laps": 3, "supportsGlitch": true, "sortOrder": 2 }
]
""",
["https://rwfc.net/api/timetrial/worldrecords/all?glitchAllowed=true&cc=150"] =
"""
[
{ "trackId": 1, "trackName": "Main Track", "activeWorldRecord": { "id": 100, "trackId": 1, "trackName": "Main Track", "playerName": "P1", "cc": 150, "finishTimeMs": 100000, "finishTimeDisplay": "1:40.000", "miiName": "Mii", "dateSet": "2026-01-01", "submittedAt": "2026-01-01T00:00:00Z", "rank": 1 } },
{ "trackId": 2, "trackName": "Variant Track", "activeWorldRecord": { "id": 101, "trackId": 2, "trackName": "Variant Track", "playerName": "P2", "cc": 150, "finishTimeMs": 101000, "finishTimeDisplay": "1:41.000", "miiName": "Mii", "dateSet": "2026-01-01", "submittedAt": "2026-01-01T00:00:00Z", "rank": 1 } }
]
"""
});

using var client = new HttpClient(handler);
var hexMapping = new TrackHexMappingService();
var variantMapping = new TrackVariantMappingService();
var service = new GhostTrackService(client, hexMapping, variantMapping);

var firstLoad = await service.GetAllTracksAsync();
var secondLoad = await service.GetAllTracksAsync();

Assert.Equal(2, firstLoad.Count);
Assert.All(firstLoad, t => Assert.Equal(string.Empty, t.HexValue));
Assert.Equal(firstLoad.Count, secondLoad.Count);

Assert.Equal(1, handler.GetRequestCount("https://rwfc.net/api/timetrial/tracks"));
Assert.Equal(1, handler.GetRequestCount("https://rwfc.net/api/timetrial/worldrecords/all?glitchAllowed=true&cc=150"));
}

[Fact]
public async Task EnsureTrackMappingsInitializedAsync_InitializesVariantMappingAfterTrackLoad()
{
var handler = new StubHttpMessageHandler(new Dictionary<string, string>
{
["https://rwfc.net/api/timetrial/tracks"] =
"""
[
{ "id": 10, "name": "Main Track", "courseId": 410, "category": "retro", "laps": 3, "supportsGlitch": true, "sortOrder": 1 },
{ "id": 11, "name": "Variant Track", "courseId": 410, "category": "retro", "laps": 3, "supportsGlitch": true, "sortOrder": 2 }
]
""",
["https://rwfc.net/api/timetrial/worldrecords/all?glitchAllowed=true&cc=150"] = "[]"
});

using var client = new HttpClient(handler);
var hexMapping = new TrackHexMappingService();
var variantMapping = new TrackVariantMappingService();
var service = new GhostTrackService(client, hexMapping, variantMapping);

await service.GetAllTracksAsync();
Assert.False(variantMapping.IsVariantTrack("Variant Track"));

await service.EnsureTrackMappingsInitializedAsync();

Assert.True(variantMapping.IsVariantTrack("Variant Track"));
Assert.Equal("Main Track", variantMapping.GetMainTrackName("Variant Track"));
}

private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<string, string> _responses;
private readonly ConcurrentDictionary<string, int> _requestCounts = new(StringComparer.OrdinalIgnoreCase);

public StubHttpMessageHandler(Dictionary<string, string> responses)
{
_responses = responses;
}

public int GetRequestCount(string url)
{
return _requestCounts.TryGetValue(url, out var count) ? count : 0;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
_requestCounts.AddOrUpdate(url, 1, (_, current) => current + 1);

if (!_responses.TryGetValue(url, out var content))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent(string.Empty)
});
}

return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(content, Encoding.UTF8, "application/json")
});
}
}
Comment on lines +77 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider using ConcurrentDictionary for thread-safety in the stub handler.

While unlikely to cause issues in these sequential tests, _requestCounts could have race conditions if tests were parallelized or if SendAsync were called concurrently. Using ConcurrentDictionary with AddOrUpdate would be more robust.

♻️ Proposed thread-safe improvement
+using System.Collections.Concurrent;
...
 private sealed class StubHttpMessageHandler : HttpMessageHandler
 {
     private readonly Dictionary<string, string> _responses;
-    private readonly Dictionary<string, int> _requestCounts = new(StringComparer.OrdinalIgnoreCase);
+    private readonly ConcurrentDictionary<string, int> _requestCounts = new(StringComparer.OrdinalIgnoreCase);

...
     protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
     {
         var url = request.RequestUri!.ToString();
-        _requestCounts[url] = GetRequestCount(url) + 1;
+        _requestCounts.AddOrUpdate(url, 1, (_, count) => count + 1);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@WheelWizard.Test/Features/Ghosts/GhostTrackServiceTests.cs` around lines 76 -
109, The StubHttpMessageHandler currently uses a non-thread-safe Dictionary
_requestCounts updated in SendAsync and read in GetRequestCount; replace
_requestCounts with a
System.Collections.Concurrent.ConcurrentDictionary<string,int> and update
SendAsync to increment counts atomically (e.g., using AddOrUpdate or
TryGetValue/CompareExchange pattern) and adjust GetRequestCount to read from the
ConcurrentDictionary; update the constructor/type declaration for _requestCounts
and ensure SendAsync, GetRequestCount, and any initializations reference the new
ConcurrentDictionary to make the handler thread-safe.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using WheelWizard.Models;
using WheelWizard.Services;

namespace WheelWizard.Test.Features.Ghosts;

public class TrackVariantMappingServiceTests
{
[Fact]
public void InitializeFromTrackInfo_BuildsVariantAndMainMappingsByCourseId()
{
var service = new TrackVariantMappingService();

var tracks = new[]
{
new TrackInfo { Id = 10, Name = "Main Track", CourseId = 300 },
new TrackInfo { Id = 11, Name = "Variant Track", CourseId = 300 },
new TrackInfo { Id = 20, Name = "Solo Track", CourseId = 301 }
};

service.InitializeFromTrackInfo(tracks);

Assert.True(service.IsVariantTrack("Variant Track"));
Assert.False(service.IsVariantTrack("Main Track"));
Assert.Equal("Main Track", service.GetMainTrackName("Variant Track"));
Assert.Equal("Solo Track", service.GetMainTrackName("Solo Track"));

var variants = service.GetVariantsForMainTrack("Main Track");
Assert.Single(variants);
Assert.Contains("Variant Track", variants);
Assert.Empty(service.GetVariantsForMainTrack("Solo Track"));
}

[Fact]
public void AddAndRemoveVariantMapping_UpdatesVariantState()
{
var service = new TrackVariantMappingService();

service.AddVariantMapping("Main A", "Variant A1");

Assert.True(service.IsVariantTrack("Variant A1"));
Assert.Equal("Main A", service.GetMainTrackName("Variant A1"));
Assert.Contains("Variant A1", service.GetVariantsForMainTrack("Main A"));

service.RemoveVariantMapping("Variant A1");

Assert.False(service.IsVariantTrack("Variant A1"));
Assert.Equal("Variant A1", service.GetMainTrackName("Variant A1"));
Assert.Empty(service.GetVariantsForMainTrack("Main A"));
}
}
Loading
Loading