mbproxy: replace status page with a live SignalR web dashboard
The single auto-refreshing zero-JS status page gave operators a 25-column wall and no way to drill into one connection. This adds a Bootstrap fleet dashboard (filterable/sortable KPI table) and a per-PLC detail page with a real-time debug view of raw PLC-side BCD vs. decoded client-side values, streamed live over a SignalR feed. The debug view is fed by an on-demand per-tag value capture, armed only while a detail page is open. All assets (Bootstrap, SignalR client, fonts) are embedded so the UI works unchanged on firewalled networks; GET /status.json is untouched for scrapers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -168,10 +168,10 @@ public sealed class AdminEndpointTests
|
||||
after.ShouldBeGreaterThan(before, "partialBcdWarnings should increment after partial overlap read");
|
||||
}
|
||||
|
||||
// ── 4. GET / returns 200 text/html with meta-refresh ─────────────────────
|
||||
// ── 4. GET / and GET /plc/{name} serve the embedded SPA shells ───────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Get_Root_ReturnsHtml_WithMetaRefresh()
|
||||
public async Task Get_Root_ReturnsDashboardShell()
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
@@ -190,8 +190,79 @@ public sealed class AdminEndpointTests
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
body.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
|
||||
body.ShouldContain("<!DOCTYPE html>");
|
||||
body.ShouldContain("<!doctype html>");
|
||||
body.ShouldContain("/assets/dashboard.js");
|
||||
}
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Get_PlcDetailRoute_ReturnsDetailShell()
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
||||
proxyPort: proxyPort, bcd16Addresses: []);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort);
|
||||
|
||||
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/plc/anything",
|
||||
TestContext.Current.CancellationToken);
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
body.ShouldContain("/assets/detail.js");
|
||||
}
|
||||
|
||||
[Theory(Timeout = 5_000)]
|
||||
[InlineData("bootstrap.min.css", "text/css")]
|
||||
[InlineData("signalr.min.js", "text/javascript")]
|
||||
[InlineData("dashboard.js", "text/javascript")]
|
||||
[InlineData("theme.css", "text/css")]
|
||||
[InlineData("ibm-plex-mono-500.woff2", "font/woff2")]
|
||||
public async Task Get_Asset_ReturnsCorrectContentType(string file, string expectedType)
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
||||
proxyPort: proxyPort, bcd16Addresses: []);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort);
|
||||
|
||||
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/{file}",
|
||||
TestContext.Current.CancellationToken);
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe(expectedType);
|
||||
response.Headers.CacheControl?.ToString().ShouldContain("immutable");
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
||||
bytes.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Get_UnknownAsset_Returns404()
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
||||
proxyPort: proxyPort, bcd16Addresses: []);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort);
|
||||
|
||||
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/no-such-file.js",
|
||||
TestContext.Current.CancellationToken);
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ── 5. AdminPort collision → proxy still runs + bind.failed logged ────────
|
||||
@@ -334,9 +405,9 @@ public sealed class AdminEndpointTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
|
||||
/// with HTTP 405 Method Not Allowed. The design intentionally exposes only `GET /`
|
||||
/// and `GET /status.json`; this test guards against an accidental MapPost/Map* being
|
||||
/// added later.
|
||||
/// against the read-only routes `GET /` and `GET /status.json` with HTTP 405.
|
||||
/// (The SignalR hub at `/hub/status` legitimately accepts POST and is not tested
|
||||
/// here.) Guards against an accidental MapPost/Map* being added later.
|
||||
/// </summary>
|
||||
[Theory(Timeout = 5_000)]
|
||||
[InlineData("POST")]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using Mbproxy.Admin;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal hand-written test doubles for the SignalR surface <see cref="StatusHub"/>
|
||||
/// and <see cref="StatusBroadcaster"/> touch. The project carries no mocking framework,
|
||||
/// so these record just enough to assert behaviour.
|
||||
/// </summary>
|
||||
internal sealed class FakeHubCallerContext : HubCallerContext
|
||||
{
|
||||
public FakeHubCallerContext(string connectionId) => ConnectionId = connectionId;
|
||||
|
||||
public override string ConnectionId { get; }
|
||||
public override string? UserIdentifier => null;
|
||||
public override ClaimsPrincipal? User => null;
|
||||
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
||||
public override IFeatureCollection Features { get; } = new FeatureCollection();
|
||||
public override CancellationToken ConnectionAborted => CancellationToken.None;
|
||||
public override void Abort() { }
|
||||
}
|
||||
|
||||
/// <summary>Records every group join/leave so tests can assert membership changes.</summary>
|
||||
internal sealed class FakeGroupManager : IGroupManager
|
||||
{
|
||||
public List<(string ConnectionId, string Group)> Added { get; } = [];
|
||||
public List<(string ConnectionId, string Group)> Removed { get; } = [];
|
||||
|
||||
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Added.Add((connectionId, groupName));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Removed.Add((connectionId, groupName));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records every push so <see cref="StatusBroadcaster"/> tests can assert routing.</summary>
|
||||
internal sealed class FakeStatusPushSink : IStatusPushSink
|
||||
{
|
||||
private readonly ConcurrentBag<StatusResponse> _fleet = [];
|
||||
private readonly ConcurrentBag<(string Plc, PlcDetailResponse Detail)> _plc = [];
|
||||
|
||||
public IReadOnlyCollection<StatusResponse> FleetPushes => _fleet;
|
||||
public IReadOnlyCollection<(string Plc, PlcDetailResponse Detail)> PlcPushes => _plc;
|
||||
|
||||
public Task PushFleetAsync(StatusResponse snapshot, CancellationToken ct)
|
||||
{
|
||||
_fleet.Add(snapshot);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PushPlcAsync(string plcName, PlcDetailResponse detail, CancellationToken ct)
|
||||
{
|
||||
_plc.Add((plcName, detail));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Mbproxy.Admin;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StatusBroadcaster"/>'s push-cycle logic — fleet always
|
||||
/// pushed, per-PLC pushed only for PLCs with a detail-page subscriber, and every
|
||||
/// capture disarmed on stop. The SignalR sink is faked; a real
|
||||
/// <see cref="StatusSnapshotBuilder"/> is resolved from a minimal in-process host.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StatusBroadcasterTests
|
||||
{
|
||||
private sealed record Harness(
|
||||
IHost Host,
|
||||
StatusBroadcaster Broadcaster,
|
||||
FakeStatusPushSink Sink,
|
||||
StatusSnapshotBuilder Builder,
|
||||
TagCaptureRegistry Registry,
|
||||
PlcSubscriptionTracker Tracker) : IAsyncDisposable
|
||||
{
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Broadcaster.DisposeAsync();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try { await Host.StopAsync(cts.Token); } catch { }
|
||||
Host.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Harness> BuildAsync()
|
||||
{
|
||||
var hostBuilder = Host.CreateApplicationBuilder();
|
||||
hostBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
});
|
||||
hostBuilder.Services.AddSerilog(
|
||||
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false);
|
||||
hostBuilder.AddMbproxyOptions();
|
||||
hostBuilder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
|
||||
hostBuilder.Services.AddSingleton<ProxyWorker>();
|
||||
hostBuilder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
||||
hostBuilder.Services.AddSingleton<AssemblyVersionAccessor>();
|
||||
hostBuilder.Services.AddSingleton<StatusSnapshotBuilder>();
|
||||
|
||||
var host = hostBuilder.Build();
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
var builder = host.Services.GetRequiredService<StatusSnapshotBuilder>();
|
||||
var registry = host.Services.GetRequiredService<TagCaptureRegistry>();
|
||||
var options = host.Services.GetRequiredService<IOptionsMonitor<MbproxyOptions>>();
|
||||
var tracker = new PlcSubscriptionTracker();
|
||||
var sink = new FakeStatusPushSink();
|
||||
|
||||
var broadcaster = new StatusBroadcaster(
|
||||
sink, builder, tracker, registry, options, NullLogger.Instance);
|
||||
|
||||
return new Harness(host, broadcaster, sink, builder, registry, tracker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushOnce_AlwaysPushesFleet()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
|
||||
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
h.Sink.FleetPushes.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushOnce_NoActivePlcs_SkipsPerPlcPush()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
|
||||
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
h.Sink.PlcPushes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushOnce_ActivePlc_PushesDetailWithDebugSnapshot()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
|
||||
h.Tracker.Add("conn-1", "plc-x");
|
||||
|
||||
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var push = h.Sink.PlcPushes.ShouldHaveSingleItem();
|
||||
push.Plc.ShouldBe("plc-x");
|
||||
push.Detail.Debug.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_DisarmsEveryCapture()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
|
||||
h.Registry.Arm("plc-x");
|
||||
|
||||
await h.Broadcaster.StopAsync();
|
||||
|
||||
h.Registry.TryGet("plc-x", out var capture).ShouldBeTrue();
|
||||
capture.IsArmed.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
using Mbproxy.Admin;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StatusHtmlRenderer"/>.
|
||||
/// All tests are pure: no network, no host, no DI.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StatusHtmlRendererTests
|
||||
{
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static StatusResponse MakeStatus(
|
||||
IReadOnlyList<PlcStatus>? 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<ClientSnapshot>? clients = null)
|
||||
{
|
||||
var noClients = (IReadOnlyList<ClientSnapshot>)[];
|
||||
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("<meta http-equiv=\"refresh\" content=\"5\">");
|
||||
html.ShouldContain("<!DOCTYPE html>");
|
||||
html.ShouldContain("</html>");
|
||||
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<PlcStatus>(plcCount);
|
||||
for (int i = 0; i < plcCount; i++)
|
||||
{
|
||||
var clients = new List<ClientSnapshot>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Mbproxy.Admin;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StatusHub"/> — group joins and on-demand capture
|
||||
/// arming. Uses hand-written SignalR test doubles (see <see cref="SignalRFakes"/>);
|
||||
/// no SignalR host is started.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StatusHubTests
|
||||
{
|
||||
private static StatusHub MakeHub(
|
||||
string connectionId,
|
||||
PlcSubscriptionTracker tracker,
|
||||
TagCaptureRegistry registry,
|
||||
out FakeGroupManager groups)
|
||||
{
|
||||
groups = new FakeGroupManager();
|
||||
return new StatusHub(tracker, registry)
|
||||
{
|
||||
Context = new FakeHubCallerContext(connectionId),
|
||||
Groups = groups,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeFleet_JoinsFleetGroup()
|
||||
{
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), new TagCaptureRegistry(), out var groups);
|
||||
|
||||
await hub.SubscribeFleet();
|
||||
|
||||
groups.Added.ShouldContain(("conn-1", StatusHub.FleetGroup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribePlc_JoinsPlcGroup_AndArmsCapture()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
|
||||
|
||||
await hub.SubscribePlc("plc-1");
|
||||
|
||||
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("plc-1")));
|
||||
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
|
||||
capture.IsArmed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondSubscriber_FirstLeaveKeepsArmed_LastLeaveDisarms()
|
||||
{
|
||||
var tracker = new PlcSubscriptionTracker();
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
|
||||
|
||||
var hub1 = MakeHub("conn-1", tracker, registry, out _);
|
||||
var hub2 = MakeHub("conn-2", tracker, registry, out _);
|
||||
|
||||
await hub1.SubscribePlc("plc-1");
|
||||
await hub2.SubscribePlc("plc-1");
|
||||
|
||||
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
|
||||
capture.IsArmed.ShouldBeTrue();
|
||||
|
||||
// First viewer leaves — a second viewer remains, so capture stays armed.
|
||||
await hub1.OnDisconnectedAsync(null);
|
||||
capture.IsArmed.ShouldBeTrue("capture must stay armed while another detail page is open");
|
||||
|
||||
// Last viewer leaves — capture disarms.
|
||||
await hub2.OnDisconnectedAsync(null);
|
||||
capture.IsArmed.ShouldBeFalse("capture must disarm when the last viewer leaves");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribePlc_UnknownPlc_DoesNotThrow_AndArmsNothing()
|
||||
{
|
||||
var registry = new TagCaptureRegistry(); // no captures registered
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
|
||||
|
||||
await Should.NotThrowAsync(async () => await hub.SubscribePlc("ghost"));
|
||||
|
||||
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("ghost")));
|
||||
registry.TryGet("ghost", out _).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -218,18 +218,60 @@ public sealed class StatusSnapshotBuilderTests
|
||||
result.Service.ConfigReloadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ── 7. BuildDebug: unknown PLC → empty, disarmed snapshot ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDebug_UnknownPlc_ReturnsEmptyDisarmedSnapshot()
|
||||
{
|
||||
var (host, builder) = await BuildAsync([]);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
|
||||
var debug = builder.BuildDebug("no-such-plc");
|
||||
|
||||
debug.CaptureArmed.ShouldBeFalse();
|
||||
debug.Tags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ── 8. BuildDebug: configured PLC → one row per BCD tag, no traffic ──────
|
||||
|
||||
[Fact]
|
||||
public async Task BuildDebug_ConfiguredPlc_ReturnsTagRows_DisarmedByDefault()
|
||||
{
|
||||
int port = PickFreePort();
|
||||
var (host, builder) = await BuildAsync([("PLC-A", port)], bcd16Address: 1072);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
|
||||
await WaitForAsync(() => CanConnect(port), TimeSpan.FromSeconds(5), "PLC-A should bind");
|
||||
|
||||
var debug = builder.BuildDebug("PLC-A");
|
||||
|
||||
debug.CaptureArmed.ShouldBeFalse(); // no detail page open
|
||||
var tag = debug.Tags.ShouldHaveSingleItem();
|
||||
tag.Address.ShouldBe(1072);
|
||||
tag.Width.ShouldBe(16);
|
||||
tag.HasValue.ShouldBeFalse();
|
||||
tag.RawHex.ShouldBe("—");
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<(IHost host, StatusSnapshotBuilder builder)> BuildAsync(
|
||||
(string name, int port)[] plcs,
|
||||
int startupWaitMs = 200,
|
||||
int backendPort = 502)
|
||||
int backendPort = 502,
|
||||
int? bcd16Address = null)
|
||||
{
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0", // disable admin for unit tests
|
||||
};
|
||||
|
||||
if (bcd16Address is { } addr)
|
||||
{
|
||||
config["Mbproxy:BcdTags:Global:0:Address"] = addr.ToString();
|
||||
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
||||
}
|
||||
|
||||
for (int i = 0; i < plcs.Length; i++)
|
||||
{
|
||||
config[$"Mbproxy:Plcs:{i}:Name"] = plcs[i].name;
|
||||
|
||||
@@ -73,7 +73,8 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
|
||||
return new ConfigReconciler(
|
||||
monitor,
|
||||
NullLoggerFactory.Instance,
|
||||
counters ?? new ServiceCounters());
|
||||
counters ?? new ServiceCounters(),
|
||||
new Mbproxy.Proxy.TagCaptureRegistry());
|
||||
}
|
||||
|
||||
// The reconciler and supervisors tracked for cleanup.
|
||||
|
||||
@@ -334,4 +334,36 @@ public sealed class ReloadValidatorTests
|
||||
Assert.False(valid);
|
||||
Assert.Contains(errors, e => e.Contains("BackendHeartbeatIdleMs"));
|
||||
}
|
||||
|
||||
// ── AdminPushIntervalMs ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_AdminPushIntervalMs_Zero_Fails()
|
||||
{
|
||||
var opts = new MbproxyOptions
|
||||
{
|
||||
Plcs = [MakePlc("PLC-A", 5020)],
|
||||
AdminPushIntervalMs = 0,
|
||||
};
|
||||
|
||||
bool valid = ReloadValidator.Validate(opts, out var errors);
|
||||
|
||||
Assert.False(valid);
|
||||
Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AdminPushIntervalMs_Negative_Fails()
|
||||
{
|
||||
var opts = new MbproxyOptions
|
||||
{
|
||||
Plcs = [MakePlc("PLC-A", 5020)],
|
||||
AdminPushIntervalMs = -5,
|
||||
};
|
||||
|
||||
bool valid = ReloadValidator.Validate(opts, out var errors);
|
||||
|
||||
Assert.False(valid);
|
||||
Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,28 @@ public sealed class MbproxyOptionsBindingTests
|
||||
string.Join("; ", result.Failures ?? []));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 7 — AdminPushIntervalMs (SignalR dashboard push cadence)
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void MbproxyOptionsBinding_AdminPushIntervalMs_DefaultsTo1000()
|
||||
{
|
||||
var options = BindOptions(new Dictionary<string, string?>());
|
||||
|
||||
options.AdminPushIntervalMs.ShouldBe(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MbproxyOptionsBinding_AdminPushIntervalMs_BindsConfiguredValue()
|
||||
{
|
||||
var options = BindOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPushIntervalMs"] = "250",
|
||||
});
|
||||
|
||||
options.AdminPushIntervalMs.ShouldBe(250);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <c>install/</c> file by walking up from the test assembly directory.
|
||||
/// Works from both the Windows dev box and the Linux test box.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Collections.Frozen;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="TagValueCapture"/> recording hooks in
|
||||
/// <see cref="BcdPduPipeline"/>. Verifies that an armed capture records raw PLC-side
|
||||
/// and decoded client-side values, and — as a regression guard — that a disarmed or
|
||||
/// absent capture leaves the rewrite behaviour byte-identical.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BcdPduPipelineCaptureTests
|
||||
{
|
||||
private static readonly BcdPduPipeline Pipeline = new();
|
||||
|
||||
private static BcdTagMap BuildMap(params BcdTag[] tags)
|
||||
{
|
||||
var frozen = tags.ToDictionary(t => t.Address).ToFrozenDictionary();
|
||||
return frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
|
||||
}
|
||||
|
||||
private static PerPlcContext MakeContext(TagValueCapture? capture, params BcdTag[] tags)
|
||||
=> new()
|
||||
{
|
||||
PlcName = "TestPLC",
|
||||
TagMap = BuildMap(tags),
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = NullLogger.Instance,
|
||||
Capture = capture,
|
||||
};
|
||||
|
||||
private static InFlightRequest MakeInFlight(byte fc, ushort start, ushort qty)
|
||||
=> new(1, fc, start, qty, Array.Empty<InterestedParty>(), DateTimeOffset.UtcNow);
|
||||
|
||||
private static byte[] Fc03Response(params ushort[] regs)
|
||||
{
|
||||
var pdu = new byte[2 + regs.Length * 2];
|
||||
pdu[0] = 0x03;
|
||||
pdu[1] = (byte)(regs.Length * 2);
|
||||
for (int i = 0; i < regs.Length; i++)
|
||||
{
|
||||
pdu[2 + i * 2] = (byte)(regs[i] >> 8);
|
||||
pdu[2 + i * 2 + 1] = (byte)(regs[i] & 0xFF);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static byte[] Fc06Request(ushort address, ushort value)
|
||||
=> [0x06, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(value >> 8), (byte)(value & 0xFF)];
|
||||
|
||||
private static byte[] Fc16Request(ushort start, params ushort[] regs)
|
||||
{
|
||||
var pdu = new byte[6 + regs.Length * 2];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(start >> 8);
|
||||
pdu[2] = (byte)(start & 0xFF);
|
||||
pdu[3] = (byte)((ushort)regs.Length >> 8);
|
||||
pdu[4] = (byte)(regs.Length & 0xFF);
|
||||
pdu[5] = (byte)(regs.Length * 2);
|
||||
for (int i = 0; i < regs.Length; i++)
|
||||
{
|
||||
pdu[6 + i * 2] = (byte)(regs[i] >> 8);
|
||||
pdu[6 + i * 2 + 1] = (byte)(regs[i] & 0xFF);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static void ProcessFc03Response(PerPlcContext ctx, ushort start, ushort qty, byte[] response)
|
||||
{
|
||||
var responseCtx = ctx.WithCurrentRequest(MakeInFlight(0x03, start, qty));
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, response.AsSpan(), responseCtx);
|
||||
}
|
||||
|
||||
private static ushort ReadReg(byte[] pdu, int offsetWords)
|
||||
=> (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]);
|
||||
|
||||
// ── Read path (FC03/FC04 response) ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC03_16Bit_ArmedCapture_RecordsRawAndDecoded()
|
||||
{
|
||||
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
|
||||
capture.Arm();
|
||||
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
|
||||
|
||||
ProcessFc03Response(ctx, 100, 1, Fc03Response(0x1234));
|
||||
|
||||
var slot = capture.Snapshot().ShouldHaveSingleItem();
|
||||
slot.Address.ShouldBe((ushort)100);
|
||||
slot.RawLow.ShouldBe((ushort)0x1234); // BCD nibbles on the PLC wire
|
||||
slot.DecodedValue.ShouldBe(1234); // binary the client receives
|
||||
slot.Direction.ShouldBe(CaptureDirection.Read);
|
||||
slot.UpdatedAtUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_32Bit_ArmedCapture_RecordsBothRawWords()
|
||||
{
|
||||
var capture = new TagValueCapture([BcdTag.Create(100, 32)]);
|
||||
capture.Arm();
|
||||
var ctx = MakeContext(capture, BcdTag.Create(100, 32));
|
||||
|
||||
// CDAB: low word 0x5678, high word 0x1234 → decoded 1234*10000 + 5678.
|
||||
ProcessFc03Response(ctx, 100, 2, Fc03Response(0x5678, 0x1234));
|
||||
|
||||
var slot = capture.Snapshot().ShouldHaveSingleItem();
|
||||
slot.Width.ShouldBe((byte)32);
|
||||
slot.RawLow.ShouldBe((ushort)0x5678);
|
||||
slot.RawHigh.ShouldBe((ushort)0x1234);
|
||||
slot.DecodedValue.ShouldBe(12345678);
|
||||
slot.Direction.ShouldBe(CaptureDirection.Read);
|
||||
}
|
||||
|
||||
// ── Write path (FC06 / FC16 request) ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC06_ArmedCapture_RecordsEncodedBcdAndClientValue()
|
||||
{
|
||||
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
|
||||
capture.Arm();
|
||||
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
|
||||
|
||||
// Client writes binary 1234; proxy encodes to BCD 0x1234 for the PLC.
|
||||
var req = Fc06Request(100, 1234);
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, req.AsSpan(), ctx);
|
||||
|
||||
var slot = capture.Snapshot().ShouldHaveSingleItem();
|
||||
slot.RawLow.ShouldBe((ushort)0x1234); // BCD nibbles sent to the PLC
|
||||
slot.DecodedValue.ShouldBe(1234); // binary the client wrote
|
||||
slot.Direction.ShouldBe(CaptureDirection.Write);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC16_16Bit_ArmedCapture_RecordsWrite()
|
||||
{
|
||||
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
|
||||
capture.Arm();
|
||||
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
|
||||
|
||||
var req = Fc16Request(100, 4321);
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, req.AsSpan(), ctx);
|
||||
|
||||
var slot = capture.Snapshot().ShouldHaveSingleItem();
|
||||
slot.RawLow.ShouldBe((ushort)0x4321);
|
||||
slot.DecodedValue.ShouldBe(4321);
|
||||
slot.Direction.ShouldBe(CaptureDirection.Write);
|
||||
}
|
||||
|
||||
// ── Regression guards: disarmed / absent capture ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC03_DisarmedCapture_StillRewrites_ButCapturesNothing()
|
||||
{
|
||||
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
|
||||
// Not armed.
|
||||
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
|
||||
|
||||
var rsp = Fc03Response(0x1234);
|
||||
ProcessFc03Response(ctx, 100, 1, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)1234); // rewrite still happened
|
||||
capture.Snapshot().ShouldHaveSingleItem().UpdatedAtUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_NullCapture_DoesNotThrow_AndStillRewrites()
|
||||
{
|
||||
var ctx = MakeContext(capture: null, BcdTag.Create(100, 16));
|
||||
|
||||
var rsp = Fc03Response(0x1234);
|
||||
Should.NotThrow(() => ProcessFc03Response(ctx, 100, 1, rsp));
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)1234);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Collections.Frozen;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TagCaptureRegistry"/> — the shared seam that arms and
|
||||
/// disarms per-PLC <see cref="TagValueCapture"/> instances.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TagCaptureRegistryTests
|
||||
{
|
||||
private static BcdTagMap Map(params (ushort addr, byte width)[] tags)
|
||||
{
|
||||
if (tags.Length == 0)
|
||||
return BcdTagMap.Empty;
|
||||
var frozen = tags
|
||||
.Select(t => BcdTag.Create(t.addr, t.width))
|
||||
.ToDictionary(t => t.Address)
|
||||
.ToFrozenDictionary();
|
||||
return new BcdTagMap(frozen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_ReturnsSameInstance_OnRepeatCall_WhenTagSetUnchanged()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
var first = registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
var second = registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
|
||||
// AddOrUpdate's update path rebuilds; both must be live and consistent.
|
||||
second.TagCount.ShouldBe(1);
|
||||
registry.TryGet("plc-1", out var current).ShouldBeTrue();
|
||||
current.ShouldBeSameAs(second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_Rebuild_PreservesArmedFlag()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
var capture = registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
capture.Arm();
|
||||
|
||||
// Hot-reload reseat: same PLC, changed tag set.
|
||||
var rebuilt = registry.GetOrCreate("plc-1", Map((100, 16), (200, 32)));
|
||||
|
||||
rebuilt.ShouldNotBeSameAs(capture);
|
||||
rebuilt.IsArmed.ShouldBeTrue("a rebuilt capture must keep capturing for an open detail page");
|
||||
rebuilt.TagCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Arm_And_Disarm_ReachTheRightCapture()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
registry.GetOrCreate("plc-2", Map((100, 16)));
|
||||
|
||||
registry.Arm("plc-1");
|
||||
|
||||
registry.TryGet("plc-1", out var c1).ShouldBeTrue();
|
||||
registry.TryGet("plc-2", out var c2).ShouldBeTrue();
|
||||
c1.IsArmed.ShouldBeTrue();
|
||||
c2.IsArmed.ShouldBeFalse();
|
||||
|
||||
registry.Disarm("plc-1");
|
||||
c1.IsArmed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisarmAll_DisarmsEveryCapture()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
registry.GetOrCreate("plc-2", Map((100, 16)));
|
||||
registry.Arm("plc-1");
|
||||
registry.Arm("plc-2");
|
||||
|
||||
registry.DisarmAll();
|
||||
|
||||
registry.TryGet("plc-1", out var c1).ShouldBeTrue();
|
||||
registry.TryGet("plc-2", out var c2).ShouldBeTrue();
|
||||
c1.IsArmed.ShouldBeFalse();
|
||||
c2.IsArmed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownPlc_Operations_AreSafeNoOps()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
|
||||
Should.NotThrow(() => registry.Arm("ghost"));
|
||||
Should.NotThrow(() => registry.Disarm("ghost"));
|
||||
Should.NotThrow(() => registry.Remove("ghost"));
|
||||
registry.TryGet("ghost", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_DropsTheCapture()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
registry.TryGet("plc-1", out _).ShouldBeTrue();
|
||||
|
||||
registry.Remove("plc-1");
|
||||
|
||||
registry.TryGet("plc-1", out _).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TagValueCapture"/> — the on-demand per-tag value store
|
||||
/// behind the connection-detail debug view.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TagValueCaptureTests
|
||||
{
|
||||
private static TagValueCapture Make(params (ushort addr, byte width)[] tags)
|
||||
=> new(tags.Select(t => BcdTag.Create(t.addr, t.width)));
|
||||
|
||||
[Fact]
|
||||
public void Disarmed_Record_IsNoOp()
|
||||
{
|
||||
var capture = Make((100, 16));
|
||||
// No Arm() call — capture starts disarmed.
|
||||
capture.Record(100, 0x1234, 0, 1234, CaptureDirection.Read);
|
||||
|
||||
capture.IsArmed.ShouldBeFalse();
|
||||
var slot = capture.Snapshot().ShouldHaveSingleItem();
|
||||
slot.UpdatedAtUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Armed_Record_UpdatesMatchingSlot()
|
||||
{
|
||||
var capture = Make((100, 16));
|
||||
capture.Arm();
|
||||
capture.Record(100, 0x1234, 0, 1234, CaptureDirection.Read);
|
||||
|
||||
var slot = capture.Snapshot().ShouldHaveSingleItem();
|
||||
slot.Address.ShouldBe((ushort)100);
|
||||
slot.Width.ShouldBe((byte)16);
|
||||
slot.RawLow.ShouldBe((ushort)0x1234);
|
||||
slot.DecodedValue.ShouldBe(1234);
|
||||
slot.Direction.ShouldBe(CaptureDirection.Read);
|
||||
slot.UpdatedAtUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Armed_Record_UnknownAddress_IsIgnored()
|
||||
{
|
||||
var capture = Make((100, 16));
|
||||
capture.Arm();
|
||||
capture.Record(999, 0x1111, 0, 1111, CaptureDirection.Read);
|
||||
|
||||
capture.Snapshot().ShouldAllBe(s => s.UpdatedAtUtc == null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disarm_ClearsAllSlots()
|
||||
{
|
||||
var capture = Make((100, 16), (200, 16));
|
||||
capture.Arm();
|
||||
capture.Record(100, 0x0042, 0, 42, CaptureDirection.Read);
|
||||
capture.Record(200, 0x0099, 0, 99, CaptureDirection.Read);
|
||||
|
||||
capture.Disarm();
|
||||
|
||||
capture.IsArmed.ShouldBeFalse();
|
||||
capture.Snapshot().ShouldAllBe(s => s.UpdatedAtUtc == null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReArm_AfterDisarm_StartsEmpty()
|
||||
{
|
||||
var capture = Make((100, 16));
|
||||
capture.Arm();
|
||||
capture.Record(100, 0x0042, 0, 42, CaptureDirection.Read);
|
||||
capture.Disarm();
|
||||
capture.Arm();
|
||||
|
||||
// No new traffic since re-arm — slot must read as empty, not the pre-disarm value.
|
||||
capture.Snapshot().ShouldHaveSingleItem().UpdatedAtUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThirtyTwoBitTag_RecordsBothRawWords()
|
||||
{
|
||||
var capture = Make((100, 32));
|
||||
capture.Arm();
|
||||
capture.Record(100, 0x5678, 0x1234, 12345678, CaptureDirection.Read);
|
||||
|
||||
var slot = capture.Snapshot().ShouldHaveSingleItem();
|
||||
slot.Width.ShouldBe((byte)32);
|
||||
slot.RawLow.ShouldBe((ushort)0x5678);
|
||||
slot.RawHigh.ShouldBe((ushort)0x1234);
|
||||
slot.DecodedValue.ShouldBe(12345678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ReturnsOneRowPerTag_OrderedByAddress()
|
||||
{
|
||||
var capture = Make((300, 16), (100, 32), (200, 16));
|
||||
capture.TagCount.ShouldBe(3);
|
||||
|
||||
var snap = capture.Snapshot();
|
||||
snap.Select(s => s.Address).ShouldBe([(ushort)100, (ushort)200, (ushort)300]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteDirection_IsPreserved()
|
||||
{
|
||||
var capture = Make((100, 16));
|
||||
capture.Arm();
|
||||
capture.Record(100, 0x0500, 0, 500, CaptureDirection.Write);
|
||||
|
||||
capture.Snapshot().ShouldHaveSingleItem().Direction.ShouldBe(CaptureDirection.Write);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentRecordAndSnapshot_NeverYieldsTornSlot()
|
||||
{
|
||||
// Invariant maintained by every Record: DecodedValue == RawLow + RawHigh.
|
||||
// A torn read (fields from two different Record calls) would break it.
|
||||
var capture = Make((100, 32));
|
||||
capture.Arm();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
bool tornObserved = false;
|
||||
|
||||
var writers = Enumerable.Range(0, 4).Select(seed => Task.Run(() =>
|
||||
{
|
||||
var rng = new Random(seed + 1);
|
||||
for (int i = 0; i < 200_000; i++)
|
||||
{
|
||||
ushort lo = (ushort)rng.Next(0, 60000);
|
||||
ushort hi = (ushort)rng.Next(0, 5000);
|
||||
capture.Record(100, lo, hi, lo + hi, CaptureDirection.Read);
|
||||
}
|
||||
}, ct)).ToArray();
|
||||
|
||||
var reader = Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < 200_000; i++)
|
||||
{
|
||||
foreach (var slot in capture.Snapshot())
|
||||
{
|
||||
if (slot.UpdatedAtUtc is null)
|
||||
continue;
|
||||
if (slot.DecodedValue != slot.RawLow + slot.RawHigh)
|
||||
tornObserved = true;
|
||||
}
|
||||
}
|
||||
}, ct);
|
||||
|
||||
await Task.WhenAll([.. writers, reader]);
|
||||
tornObserved.ShouldBeFalse("Snapshot must never observe a torn (half-updated) slot");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user