This commit is contained in:
Zeeshaun
2026-04-21 15:57:09 +00:00
parent c52bd0c50c
commit 6b3408a1df
20 changed files with 1136 additions and 330 deletions
+369 -16
View File
@@ -47,6 +47,42 @@ catch (Exception exception)
failures.Add($"Path resolution test failed: {exception.Message}");
}
try
{
RunRenderJobContractDefaultsTest();
}
catch (Exception exception)
{
failures.Add($"Render contract test failed: {exception.Message}");
}
try
{
await RunRescoreReplacementTestAsync();
}
catch (Exception exception)
{
failures.Add($"Rescore replacement test failed: {exception.Message}");
}
try
{
await RunParserFailureTestAsync();
}
catch (Exception exception)
{
failures.Add($"Parser failure test failed: {exception.Message}");
}
try
{
await RunInvalidClipUploadTestAsync();
}
catch (Exception exception)
{
failures.Add($"Invalid clip upload test failed: {exception.Message}");
}
if (failures.Count > 0)
{
foreach (var failure in failures)
@@ -94,7 +130,7 @@ static void RunScoringTest()
[7] = new() { Id = 7, Name = "Alpha", Side = "CT" }
};
var first = new EventRecord { Id = 1, RoundId = 1, Tick = 100, ActorPlayerId = 7, EventType = "kill" };
var first = new EventRecord { Id = 1, RoundId = 1, Tick = 100, ActorPlayerId = 7, EventType = EventTypes.Kill };
var target = new EventRecord
{
Id = 2,
@@ -102,7 +138,7 @@ static void RunScoringTest()
Tick = 450,
Second = 38,
ActorPlayerId = 7,
EventType = "kill",
EventType = EventTypes.Kill,
IsHeadshot = true,
KillerAliveCount = 1,
EnemyAliveCount = 3
@@ -115,6 +151,10 @@ static void RunScoringTest()
Assert(result.Tags.Contains("multikill"), "Expected multikill tag.");
Assert(result.Tags.Contains("clutch"), "Expected clutch tag.");
Assert(result.Tags.Contains("1vX"), "Expected 1vX tag.");
Assert(result.StartTick == 290, "Expected clip window pre-roll to be applied.");
Assert(result.EndTick == 546, "Expected clip window post-roll to be applied.");
Assert(result.Explanation.Any(item => item.Reason == "headshot"), "Expected score explanation to include headshot.");
Assert(result.Explanation.Any(item => item.Reason == "clutch_1v3"), "Expected score explanation to include clutch weight.");
}
static async Task RunFlowTestAsync()
@@ -146,7 +186,7 @@ static async Task RunFlowTestAsync()
var store = new JsonAppStore(options);
var scoringProvider = new ScoringRulesProvider(environment);
var renderWorker = new NoOpRenderWorkerAdapter();
var renderWorker = new RecordingRenderWorkerAdapter();
var scoring = new MatchScoringService(scoringProvider, renderWorker, options);
var ingest = new DemoIngestService(store, options, new SampleDemoParserAdapter(), scoring);
var clipMedia = new ClipMediaService(store, options);
@@ -159,17 +199,18 @@ static async Task RunFlowTestAsync()
var state = await store.GetSnapshotAsync();
Assert(state.Demos.Count == 1, "Expected one demo.");
Assert(state.Demos[0].Status == "parsed", "Expected parsed demo status.");
Assert(state.Demos[0].Status == DemoStatuses.Parsed, "Expected parsed demo status.");
Assert(state.Matches.Count == 1, "Expected one match.");
Assert(state.Highlights.Count > 0, "Expected highlights.");
Assert(state.ClipJobs.Count == state.Highlights.Count, "Expected one clip job per highlight.");
Assert(state.ClipJobs[0].AttemptCount == 0, "Expected new clip job to have no worker attempts yet.");
Assert(renderWorker.EnqueuedClipJobIds.Count == state.ClipJobs.Count, "Expected every created clip job to be enqueued.");
await using var clipPayload = new MemoryStream("fake rendered mp4 bytes"u8.ToArray());
await clipMedia.AttachClipAssetAsync(state.ClipJobs[0].Id, "sample.mp4", "video/mp4", clipPayload, CancellationToken.None);
await clipMedia.AttachClipAssetAsync(state.ClipJobs[0].Id, $"sample{ClipMediaFormats.Mp4Extension}", ClipMediaFormats.Mp4ContentType, clipPayload, CancellationToken.None);
state = await store.GetSnapshotAsync();
Assert(state.ClipAssets.Count == 1, "Expected one clip asset.");
Assert(state.ClipJobs[0].Status == "rendered", "Expected rendered clip status after attach.");
Assert(state.ClipJobs[0].Status == ClipJobStatuses.Rendered, "Expected rendered clip status after attach.");
Assert(state.ClipJobs[0].Payload.TryGetValue("version", out var version) && version == RenderJobContract.CurrentVersion, "Expected versioned render payload.");
Assert(state.ClipJobs[0].Payload.TryGetValue("demoId", out var demoIdPayload) && demoIdPayload == "1", "Expected demo id in render payload.");
Assert(state.ClipJobs[0].Payload.ContainsKey("cameraMode"), "Expected render payload defaults on clip job.");
@@ -198,18 +239,18 @@ static void RunRetryFlowTest()
{
Id = 1,
HighlightId = 7,
Renderer = "future-renderer",
Status = "pending",
Renderer = RenderBackendNames.FutureRenderer,
Status = ClipJobStatuses.Pending,
StartTick = 100,
EndTick = 200,
MaxAttempts = 2
};
Assert(ClipJobRetryPolicy.CanStartAttempt(firstJob, DateTime.UtcNow), "Expected pending job to be claimable before any attempts.");
ClipJobRetryPolicy.MarkAttemptStarted(firstJob, "sample-copy", DateTime.UtcNow);
ClipJobRetryPolicy.MarkAttemptStarted(firstJob, RenderBackendNames.SampleCopy, DateTime.UtcNow);
ClipJobRetryPolicy.MarkAttemptFailed(firstJob, "Simulated backend failure.", DateTime.UtcNow, TimeSpan.FromSeconds(30));
Assert(firstJob.Status == "pending", "Expected failed render with retries remaining to return to pending.");
Assert(firstJob.Status == ClipJobStatuses.Pending, "Expected failed render with retries remaining to return to pending.");
Assert(firstJob.AttemptCount == 1, "Expected first failed attempt to increment attempt count.");
Assert(firstJob.RetryAfterUtc.HasValue, "Expected retry to be scheduled after a failure.");
Assert(!string.IsNullOrWhiteSpace(firstJob.ErrorMessage), "Expected failure message to be preserved for retry visibility.");
@@ -218,13 +259,13 @@ static void RunRetryFlowTest()
firstJob.RetryAfterUtc = DateTime.UtcNow.AddSeconds(-1);
Assert(ClipJobRetryPolicy.CanStartAttempt(firstJob, DateTime.UtcNow), "Expected job to become claimable once retry delay passes.");
ClipJobRetryPolicy.MarkAttemptStarted(firstJob, "sample-copy", DateTime.UtcNow);
firstJob.Status = "rendered";
ClipJobRetryPolicy.MarkAttemptStarted(firstJob, RenderBackendNames.SampleCopy, DateTime.UtcNow);
firstJob.Status = ClipJobStatuses.Rendered;
firstJob.ErrorMessage = null;
firstJob.RetryAfterUtc = null;
firstJob.CompletedAtUtc = DateTime.UtcNow;
Assert(firstJob.Status == "rendered", "Expected retry success to render the clip.");
Assert(firstJob.Status == ClipJobStatuses.Rendered, "Expected retry success to render the clip.");
Assert(firstJob.AttemptCount == 2, "Expected successful retry to record the second attempt.");
Assert(firstJob.RetryAfterUtc is null, "Expected successful retry to clear the retry schedule.");
@@ -232,13 +273,13 @@ static void RunRetryFlowTest()
{
Id = 2,
HighlightId = 8,
Status = "pending",
Status = ClipJobStatuses.Pending,
MaxAttempts = 1
};
ClipJobRetryPolicy.MarkAttemptStarted(terminalJob, "sample-copy", DateTime.UtcNow);
ClipJobRetryPolicy.MarkAttemptStarted(terminalJob, RenderBackendNames.SampleCopy, DateTime.UtcNow);
ClipJobRetryPolicy.MarkAttemptFailed(terminalJob, "Permanent failure.", DateTime.UtcNow, TimeSpan.FromSeconds(30));
Assert(terminalJob.Status == "failed", "Expected job with no retries remaining to become failed.");
Assert(terminalJob.Status == ClipJobStatuses.Failed, "Expected job with no retries remaining to become failed.");
Assert(terminalJob.RetryAfterUtc is null, "Expected terminal failure to avoid scheduling another retry.");
}
@@ -257,6 +298,258 @@ static void RunPathResolutionTest()
Assert(resolvedFromBaseDirectory == root, "Expected app base directory traversal to find the project root.");
}
static void RunRenderJobContractDefaultsTest()
{
var minimalPayload = new Dictionary<string, string>
{
["demoId"] = "9",
["matchId"] = "17",
["highlightId"] = "23",
["sourceEventId"] = "42",
["startTick"] = "1500",
["endTick"] = "1800"
};
var contract = RenderJobContract.FromPayload(minimalPayload);
Assert(contract.Version == RenderJobContract.CurrentVersion, "Expected missing version to fall back to current contract version.");
Assert(contract.CameraMode == RenderJobContract.DefaultCameraMode, "Expected default camera mode.");
Assert(contract.OutputFormat == RenderJobContract.DefaultOutputFormat, "Expected default output format.");
Assert(contract.Resolution == RenderJobContract.DefaultResolution, "Expected default resolution.");
Assert(contract.Fps == RenderJobContract.DefaultFps, "Expected default fps.");
Assert(contract.GetSuggestedFileExtension() == ClipMediaFormats.Mp4Extension, "Expected mp4 extension for default output format.");
contract.OutputFormat = "webm";
Assert(contract.GetSuggestedFileExtension() == ClipMediaFormats.WebMExtension, "Expected webm extension override.");
}
static async Task RunRescoreReplacementTestAsync()
{
var root = Path.Combine(Path.GetTempPath(), "fragalytics-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
Directory.CreateDirectory(Path.Combine(root, "data"));
Directory.CreateDirectory(Path.Combine(root, "uploads"));
var environment = new FakeEnvironment(root);
var options = Options.Create(new FragalyticsOptions
{
DataDirectory = Path.Combine(root, "data"),
UploadDirectory = Path.Combine(root, "uploads"),
AutoStartWorker = false,
RenderWorker = new RenderWorkerOptions
{
MaxAttempts = 3,
RetryDelaySeconds = 15
}
});
var scoringRules = """
{
"name": "test-rules",
"weights": {
"base_kill": 10,
"headshot": 8,
"opening_kill": 12,
"wallbang": 10,
"smoke_kill": 10,
"round_win": 6,
"multikill_2": 8,
"multikill_3": 18,
"multikill_4": 30,
"multikill_5": 45,
"clutch_1v2": 18,
"clutch_1v3": 28,
"clutch_1v4": 40,
"clutch_1v5": 55,
"impact_last_enemy": 12,
"impact_low_time": 4
},
"clip": {
"preTicks": 160,
"postTicks": 96
},
"thresholds": {
"highlightScoreMin": 18
}
}
""";
File.WriteAllText(Path.Combine(root, "scoring-rules.json"), scoringRules);
var store = new JsonAppStore(options);
var scoringProvider = new ScoringRulesProvider(environment);
var renderWorker = new RecordingRenderWorkerAdapter();
var parser = new StaticParserAdapter(BuildRescoreParsedDemo());
var scoring = new MatchScoringService(scoringProvider, renderWorker, options);
var ingest = new DemoIngestService(store, options, parser, scoring);
var clipMedia = new ClipMediaService(store, options);
await using var payload = new MemoryStream("rescore demo"u8.ToArray());
var demoId = await ingest.SaveUploadAsync("rescore.dem", payload, CancellationToken.None);
await ingest.ProcessNextAsync(CancellationToken.None);
var state = await store.GetSnapshotAsync();
Assert(state.Highlights.Count == 1, "Expected only one above-threshold highlight from the initial parse.");
Assert(state.ClipJobs.Count == 1, "Expected one clip job for one highlight.");
Assert(renderWorker.EnqueuedClipJobIds.Count == 1, "Expected initial highlight to be enqueued once.");
var initialHighlightId = state.Highlights[0].Id;
var initialClipJobId = state.ClipJobs[0].Id;
await using var clipPayload = new MemoryStream("first clip bytes"u8.ToArray());
await clipMedia.AttachClipAssetAsync(initialClipJobId, $"initial{ClipMediaFormats.Mp4Extension}", ClipMediaFormats.Mp4ContentType, clipPayload, CancellationToken.None);
state = await store.GetSnapshotAsync();
Assert(state.ClipAssets.Count == 1, "Expected one attached clip asset before rescoring.");
File.WriteAllText(
Path.Combine(root, "scoring-rules.json"),
scoringRules.Replace("\"highlightScoreMin\": 18", "\"highlightScoreMin\": 8", StringComparison.Ordinal));
var rescoredCount = await ingest.RescoreAllAsync(CancellationToken.None);
Assert(rescoredCount == 2, "Expected rescoring to create two highlights after lowering the threshold.");
state = await store.GetSnapshotAsync();
Assert(state.Highlights.Count == 2, "Expected rescoring to replace old highlights with the new set.");
Assert(state.Highlights.All(item => item.Id != initialHighlightId), "Expected rescoring to replace prior highlight records.");
Assert(state.ClipJobs.Count == 2, "Expected clip jobs to be regenerated on rescoring.");
Assert(state.ClipJobs.All(item => item.Id != initialClipJobId), "Expected prior clip jobs to be discarded on rescoring.");
Assert(state.ClipAssets.Count == 0, "Expected clip assets tied to old clip jobs to be removed during rescoring.");
Assert(renderWorker.EnqueuedClipJobIds.Count == 3, "Expected rescoring to enqueue the replacement clip jobs.");
Assert(state.ClipJobs.All(item => item.MaxAttempts == 3), "Expected regenerated clip jobs to preserve configured retry policy.");
Assert(state.ScoringProfiles.Count == 2, "Expected a new scoring profile version after rules changed.");
Assert(state.ScoringProfiles.Count(item => item.IsActive) == 1, "Expected only one active scoring profile.");
var rescoredHighlightsByEvent = state.Highlights.ToDictionary(item => item.SourceEventId);
Assert(rescoredHighlightsByEvent.Count == 2, "Expected each source event to map to one highlight after rescoring.");
Assert(rescoredHighlightsByEvent.Values.Any(item => item.Score < 18), "Expected rescoring to include a previously filtered low-score highlight.");
var regeneratedPayload = state.ClipJobs
.Select(item => RenderJobContract.FromPayload(item.Payload))
.ToList();
Assert(regeneratedPayload.All(item => item.DemoId == demoId), "Expected regenerated payloads to retain the demo id.");
Assert(regeneratedPayload.All(item => item.OutputFormat == RenderJobContract.DefaultOutputFormat), "Expected regenerated payloads to keep default render settings.");
}
static async Task RunParserFailureTestAsync()
{
var root = Path.Combine(Path.GetTempPath(), "fragalytics-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
Directory.CreateDirectory(Path.Combine(root, "data"));
Directory.CreateDirectory(Path.Combine(root, "uploads"));
var environment = new FakeEnvironment(root);
var options = Options.Create(new FragalyticsOptions
{
DataDirectory = Path.Combine(root, "data"),
UploadDirectory = Path.Combine(root, "uploads"),
AutoStartWorker = false
});
var repoRoot = FindRepoRoot();
File.Copy(
Path.Combine(repoRoot, "src", "Fragalytics.Web", "scoring-rules.json"),
Path.Combine(root, "scoring-rules.json"),
overwrite: true);
var store = new JsonAppStore(options);
var scoringProvider = new ScoringRulesProvider(environment);
var scoring = new MatchScoringService(scoringProvider, new RecordingRenderWorkerAdapter(), options);
var ingest = new DemoIngestService(store, options, new ThrowingParserAdapter("Simulated parser failure."), scoring);
await using var payload = new MemoryStream("broken demo"u8.ToArray());
var demoId = await ingest.SaveUploadAsync("broken.dem", payload, CancellationToken.None);
var processed = await ingest.ProcessNextAsync(CancellationToken.None);
Assert(processed == 0, "Expected failed parse to report no processed demo.");
var state = await store.GetSnapshotAsync();
Assert(state.Demos.Count == 1, "Expected failed parse to retain the demo record.");
Assert(state.Demos[0].Id == demoId, "Expected the failed demo to remain addressable.");
Assert(state.Demos[0].Status == DemoStatuses.Failed, "Expected parser failure to mark the demo as failed.");
Assert(state.Demos[0].ErrorMessage == "Simulated parser failure.", "Expected parser failure message to be recorded.");
Assert(state.Demos[0].ProcessedAtUtc.HasValue, "Expected failed parser attempt to stamp processed time.");
Assert(state.Matches.Count == 0, "Expected failed parser attempt to avoid creating a match.");
Assert(state.Events.Count == 0, "Expected failed parser attempt to avoid creating events.");
Assert(state.Highlights.Count == 0, "Expected failed parser attempt to avoid creating highlights.");
Assert(state.ClipJobs.Count == 0, "Expected failed parser attempt to avoid creating clip jobs.");
}
static async Task RunInvalidClipUploadTestAsync()
{
var root = Path.Combine(Path.GetTempPath(), "fragalytics-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
Directory.CreateDirectory(Path.Combine(root, "data"));
Directory.CreateDirectory(Path.Combine(root, "uploads"));
var environment = new FakeEnvironment(root);
var options = Options.Create(new FragalyticsOptions
{
DataDirectory = Path.Combine(root, "data"),
UploadDirectory = Path.Combine(root, "uploads"),
AutoStartWorker = false
});
var repoRoot = FindRepoRoot();
File.Copy(
Path.Combine(repoRoot, "src", "Fragalytics.Web", "scoring-rules.json"),
Path.Combine(root, "scoring-rules.json"),
overwrite: true);
var store = new JsonAppStore(options);
var scoringProvider = new ScoringRulesProvider(environment);
var scoring = new MatchScoringService(scoringProvider, new RecordingRenderWorkerAdapter(), options);
var ingest = new DemoIngestService(store, options, new SampleDemoParserAdapter(), scoring);
var clipMedia = new ClipMediaService(store, options);
await using var payload = new MemoryStream("valid demo"u8.ToArray());
await ingest.SaveUploadAsync("valid.dem", payload, CancellationToken.None);
await ingest.ProcessNextAsync(CancellationToken.None);
var state = await store.GetSnapshotAsync();
Assert(state.ClipJobs.Count > 0, "Expected parsed demo to create a clip job for invalid upload test.");
var clipJobId = state.ClipJobs[0].Id;
await using var invalidClip = new MemoryStream("not a video"u8.ToArray());
var exception = await AssertThrowsAsync<InvalidOperationException>(
() => clipMedia.AttachClipAssetAsync(clipJobId, "invalid.avi", "video/x-msvideo", invalidClip, CancellationToken.None),
"Expected unsupported clip extension to be rejected.");
Assert(exception.Message.Contains(ClipMediaFormats.Mp4Extension, StringComparison.Ordinal), "Expected invalid clip upload message to mention mp4 support.");
Assert(exception.Message.Contains(ClipMediaFormats.WebMExtension, StringComparison.Ordinal), "Expected invalid clip upload message to mention webm support.");
state = await store.GetSnapshotAsync();
Assert(state.ClipAssets.Count == 0, "Expected invalid clip upload to avoid creating clip assets.");
Assert(state.ClipJobs[0].Id == clipJobId, "Expected invalid clip upload to leave the same clip job in place.");
Assert(state.ClipJobs[0].Status == ClipJobStatuses.Pending, "Expected invalid clip upload to avoid changing clip job status.");
Assert(state.ClipJobs[0].ClipAssetId is null, "Expected invalid clip upload to avoid linking a clip asset.");
}
static ParsedDemo BuildRescoreParsedDemo()
{
return new ParsedDemo
{
MapName = "test_rescore_map",
ServerName = "Deterministic Test Parser",
TickRate = 64,
TeamCtName = "Counter-Terrorists",
TeamTName = "Terrorists",
Players =
[
new() { SteamId = "1001", Name = "Alpha", TeamName = "Counter-Terrorists", Side = "CT" },
new() { SteamId = "1002", Name = "Bravo", TeamName = "Counter-Terrorists", Side = "CT" },
new() { SteamId = "1003", Name = "Charlie", TeamName = "Terrorists", Side = "T" }
],
Rounds =
[
new() { Number = 1, StartTick = 0, EndTick = 600, WinnerSide = "CT", WinnerTeamName = "Counter-Terrorists", Reason = "elimination" }
],
KillEvents =
[
new() { Tick = 120, Second = 5.0, RoundNumber = 1, KillerSteamId = "1001", KillerName = "Alpha", VictimSteamId = "1003", VictimName = "Charlie", Weapon = "m4a1", IsHeadshot = true, KillerAliveCount = 2, EnemyAliveCount = 2 },
new() { Tick = 280, Second = 18.0, RoundNumber = 1, KillerSteamId = "1002", KillerName = "Bravo", VictimSteamId = "1003", VictimName = "Charlie", Weapon = "famas", KillerAliveCount = 2, EnemyAliveCount = 2 }
]
};
}
static string FindRepoRoot()
{
var current = AppContext.BaseDirectory;
@@ -288,6 +581,21 @@ static void Assert(bool condition, string message)
}
}
static async Task<TException> AssertThrowsAsync<TException>(Func<Task> action, string message)
where TException : Exception
{
try
{
await action();
}
catch (TException exception)
{
return exception;
}
throw new InvalidOperationException(message);
}
internal sealed class FakeEnvironment : IWebHostEnvironment
{
public FakeEnvironment(string rootPath)
@@ -307,3 +615,48 @@ internal sealed class FakeEnvironment : IWebHostEnvironment
public string ContentRootPath { get; set; }
public IFileProvider ContentRootFileProvider { get; set; }
}
internal sealed class RecordingRenderWorkerAdapter : IRenderWorkerAdapter
{
public string Name => "recording-renderer";
public List<int> EnqueuedClipJobIds { get; } = [];
public void Enqueue(ClipJobRecord clipJob)
{
EnqueuedClipJobIds.Add(clipJob.Id);
}
}
internal sealed class StaticParserAdapter : IDemoParserAdapter
{
private readonly ParsedDemo _parsedDemo;
public StaticParserAdapter(ParsedDemo parsedDemo)
{
_parsedDemo = parsedDemo;
}
public string Name => "static-test";
public Task<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken)
{
return Task.FromResult(_parsedDemo);
}
}
internal sealed class ThrowingParserAdapter : IDemoParserAdapter
{
private readonly string _message;
public ThrowingParserAdapter(string message)
{
_message = message;
}
public string Name => "throwing-test";
public Task<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken)
{
throw new InvalidOperationException(_message);
}
}