diff --git a/Backend.Tests/EndToEndSmokeTests.cs b/Backend.Tests/EndToEndSmokeTests.cs new file mode 100644 index 0000000..1ce8b10 --- /dev/null +++ b/Backend.Tests/EndToEndSmokeTests.cs @@ -0,0 +1,95 @@ +using System.IO; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Backend; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Backend.Tests; + +public class EndToEndSmokeTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public EndToEndSmokeTests(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(b => + { + // Prevent MVC picking up assemblies; force env vars if needed. + b.UseEnvironment("Development"); + }); + } + + [Fact] + public async Task WebSocket_ReceivesInitAndStateFrames() + { + // Prerequisites: publish/GameCli/GameCli and models/ppo_lander.onnx must exist. + var repoRoot = FindRepoRoot(); + Assert.True(File.Exists(Path.Combine(repoRoot, "publish", "GameCli", "GameCli")), + "run `dotnet publish GameCli -c Release -o publish/GameCli` before this test"); + Assert.True(File.Exists(Path.Combine(repoRoot, "models", "ppo_lander.onnx")), + "run `python Training/export_onnx.py ...` before this test"); + + var client = _factory.Server.CreateWebSocketClient(); + var baseUri = _factory.Server.BaseAddress; + var wsUri = new UriBuilder(baseUri) { Scheme = "ws", Path = "/ws/game" }.Uri; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var ws = await client.ConnectAsync(wsUri, cts.Token); + + var initText = await ReceiveTextAsync(ws, cts.Token); + using var initDoc = JsonDocument.Parse(initText); + Assert.Equal("init", initDoc.RootElement.GetProperty("type").GetString()); + + // Send a cursor and receive at least one state frame. + var cursorPayload = """{"type":"cursor","x":0.3,"y":0.7}"""; + await ws.SendAsync(Encoding.UTF8.GetBytes(cursorPayload), + WebSocketMessageType.Text, true, cts.Token); + + // Read up to N frames looking for a state frame; the ticker runs at 50 Hz. + for (int i = 0; i < 100; i++) + { + var text = await ReceiveTextAsync(ws, cts.Token); + using var doc = JsonDocument.Parse(text); + if (doc.RootElement.GetProperty("type").GetString() == "state") + { + Assert.True(doc.RootElement.TryGetProperty("x", out _)); + Assert.True(doc.RootElement.TryGetProperty("y", out _)); + Assert.True(doc.RootElement.TryGetProperty("angle", out _)); + Assert.True(doc.RootElement.TryGetProperty("engine", out _)); + Assert.True(doc.RootElement.TryGetProperty("target", out _)); + Assert.True(doc.RootElement.TryGetProperty("step", out _)); + return; + } + } + Assert.Fail("did not receive a state frame in 100 messages"); + } + + private static async Task ReceiveTextAsync(WebSocket ws, CancellationToken ct) + { + var buffer = new byte[16 * 1024]; + var sb = new StringBuilder(); + WebSocketReceiveResult result; + do + { + result = await ws.ReceiveAsync(buffer, ct); + sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } while (!result.EndOfMessage); + return sb.ToString(); + } + + private static string FindRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (dir is not null) + { + if (File.Exists(Path.Combine(dir, "GameCli.sln"))) return dir; + var parent = Directory.GetParent(dir); + if (parent is null) break; + dir = parent.FullName; + } + throw new InvalidOperationException("repo root not found"); + } +}