using Mbproxy.Admin; using Shouldly; using Xunit; namespace Mbproxy.Tests.Admin; /// /// Unit tests for . /// All tests are pure: no network, no host, no DI. /// [Trait("Category", "Unit")] public sealed class StatusHtmlRendererTests { // ── Helpers ─────────────────────────────────────────────────────────────── private static StatusResponse MakeStatus( IReadOnlyList? plcs = null, int uptimeSeconds = 42, string version = "1.2.3") { var service = new ServiceFields( UptimeSeconds: uptimeSeconds, Version: version, ConfigLastReloadUtc: null, ConfigReloadCount: 0, ConfigReloadRejectedCount: 0); var listeners = new ListenersAggregate(Bound: plcs?.Count ?? 0, Configured: plcs?.Count ?? 0); return new StatusResponse(service, listeners, plcs ?? []); } private static PlcStatus MakePlc( string name = "PLC-A", string state = "bound", string? lastBindError = null, int recoveryAttempts = 0, IReadOnlyList? clients = null) { var noClients = (IReadOnlyList)[]; return new PlcStatus( Name: name, Host: "10.0.0.1", ListenPort: 5020, Listener: new PlcListenerStatus(state, lastBindError, recoveryAttempts), Clients: new PlcClientsStatus(clients?.Count ?? 0, clients ?? noClients), Pdus: new PlcPdusStatus(100, new FcCounts(50, 10, 20, 15, 5), 30, 2, 0), Backend: new PlcBackendStatus( ConnectsSuccess: 0, ConnectsFailed: 0, ExceptionsByCode: new ExceptionCounts(1, 0, 0, 0, 0), LastRoundTripMs: 3.5, InFlight: 0, MaxInFlight: 0, TxIdWraps: 0, DisconnectCascades: 0, QueueDepth: 0, CoalescedHitCount: 0, CoalescedMissCount: 0, CoalescedResponseToDeadUpstream: 0, CacheHitCount: 0, CacheMissCount: 0, CacheInvalidations: 0, CacheEntryCount: 0, CacheBytes: 0, BackendHeartbeatsSent: 0, BackendHeartbeatsFailed: 0, BackendIdleDisconnects: 0), Bytes: new PlcBytesStatus(1024, 2048)); } // ── 1. Valid HTML with meta-refresh for a single PLC ───────────────────── [Fact] public void Render_OnePlc_ProducesValidHtml_WithMetaRefresh() { var status = MakeStatus([MakePlc("PLC-A", "bound")]); string html = StatusHtmlRenderer.Render(status); html.ShouldContain(""); html.ShouldContain(""); html.ShouldContain(""); html.ShouldContain("PLC-A"); html.ShouldContain("bound"); } // ── 2. Recovering state highlights error ───────────────────────────────── [Fact] public void Render_RecoveringPlc_HighlightsState() { var plc = MakePlc("PLC-B", "recovering", lastBindError: "Address already in use", recoveryAttempts: 3); var status = MakeStatus([plc]); string html = StatusHtmlRenderer.Render(status); // State should be orange. html.ShouldContain("class=\"recovering\""); html.ShouldContain("Address already in use"); html.ShouldContain("attempt 3"); } // ── 3. Page weight under 50 KB for 54 PLCs ─────────────────────────────── [Fact] public void Render_PageWeightUnder50KB_For54Plcs() { const int plcCount = 54; // Build 54 realistic PLC rows with 2 clients each. var plcs = new List(plcCount); for (int i = 0; i < plcCount; i++) { var clients = new List { new ClientSnapshot($"10.0.0.{i + 1}:49123", DateTimeOffset.UtcNow, 42), new ClientSnapshot($"10.0.0.{i + 1}:49124", DateTimeOffset.UtcNow, 17), }; plcs.Add(MakePlc( name: $"Line{i / 10 + 1}-Station{i % 10 + 1:D2}", state: i % 5 == 0 ? "recovering" : "bound", lastBindError: i % 5 == 0 ? "EADDRINUSE" : null, recoveryAttempts: i % 5 == 0 ? 2 : 0, clients: clients)); } var status = MakeStatus(plcs); string html = StatusHtmlRenderer.Render(status); int byteCount = System.Text.Encoding.UTF8.GetByteCount(html); // Assert ≤ 50 KB. byteCount.ShouldBeLessThanOrEqualTo(50 * 1024, $"Page weight {byteCount} bytes exceeds 50 KB limit for {plcCount} PLCs"); } }