feat(hosts): driver diagnostics channel on /hosts, S7 negotiated PDU size first (#640)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+187
@@ -0,0 +1,187 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the <see cref="IDriverDiagnostics"/> consumer (Gitea #640). A diagnostic fact (S7's negotiated
|
||||
/// PDU size) moves no other fingerprint component, so unless its digest is in
|
||||
/// <c>PublishHealthSnapshot</c>'s fingerprint the dedup swallows the very publish that carries it.
|
||||
/// <para>The stub's health is deliberately STABLE: the shared <c>StubDriver.GetHealth()</c> returns
|
||||
/// <c>UtcNow</c>, so the dedup never engages and a test on it passes vacuously.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverInstanceActorDiagnosticsTests : RuntimeActorTestBase
|
||||
{
|
||||
/// <summary>Base case: the bag reaches the publisher.</summary>
|
||||
[Fact]
|
||||
public void Diagnostics_reach_the_health_publisher()
|
||||
{
|
||||
var driver = new DiagStubDriver();
|
||||
driver.Set(("S7.NegotiatedPduSize", "480"));
|
||||
var publisher = new RecordingHealthPublisher();
|
||||
var actor = SpawnDriverActor(driver, publisher);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var latest = publisher.Published.LastOrDefault();
|
||||
latest.ShouldNotBeNull();
|
||||
latest!["S7.NegotiatedPduSize"].ShouldBe("480");
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The load-bearing case. Falsifiability: publish count must STRICTLY increase across the change;
|
||||
/// drop <c>DiagnosticsDigest</c> from the fingerprint and this goes red.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_diagnostics_change_republishes_despite_otherwise_unchanged_health()
|
||||
{
|
||||
var driver = new DiagStubDriver();
|
||||
driver.Set(("S7.NegotiatedPduSize", "240"));
|
||||
var publisher = new RecordingHealthPublisher();
|
||||
var actor = SpawnDriverActor(driver, publisher);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
var before = publisher.Published.Count;
|
||||
|
||||
driver.Set(("S7.NegotiatedPduSize", "480"));
|
||||
|
||||
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(before), TimeSpan.FromSeconds(3));
|
||||
publisher.Published[^1]!["S7.NegotiatedPduSize"].ShouldBe("480");
|
||||
}
|
||||
|
||||
/// <summary>Equal-but-distinct bags must be deduped: the digest compares by VALUE, not reference.</summary>
|
||||
[Fact]
|
||||
public void An_unchanged_diagnostics_bag_does_not_defeat_the_dedup()
|
||||
{
|
||||
var driver = new DiagStubDriver();
|
||||
driver.Set(("S7.NegotiatedPduSize", "480"));
|
||||
var publisher = new RecordingHealthPublisher();
|
||||
var actor = SpawnDriverActor(driver, publisher);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
var before = publisher.Published.Count;
|
||||
|
||||
driver.Set(("S7.NegotiatedPduSize", "480"));
|
||||
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(600));
|
||||
publisher.Published.Count.ShouldBe(before);
|
||||
}
|
||||
|
||||
/// <summary>A driver without the capability publishes null, not an empty bag.</summary>
|
||||
[Fact]
|
||||
public void A_driver_without_the_capability_publishes_null()
|
||||
{
|
||||
var publisher = new RecordingHealthPublisher();
|
||||
var actor = SpawnDriverActor(new StubDriver(), publisher);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
||||
|
||||
publisher.Published.ShouldAllBe(p => p == null);
|
||||
}
|
||||
|
||||
/// <summary>A throwing implementation loses only the diagnostics, not the health publish.</summary>
|
||||
[Fact]
|
||||
public void A_throwing_diagnostics_provider_degrades_to_null_without_killing_the_health_publish()
|
||||
{
|
||||
var driver = new DiagStubDriver { Throw = true };
|
||||
var publisher = new RecordingHealthPublisher();
|
||||
var actor = SpawnDriverActor(driver, publisher);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
|
||||
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
||||
publisher.Published[^1].ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>The bag is capped and truncated before it is published.</summary>
|
||||
[Fact]
|
||||
public void Oversized_bags_are_bounded()
|
||||
{
|
||||
var driver = new DiagStubDriver();
|
||||
driver.Set([.. Enumerable.Range(0, 40).Select(i => ($"k{i:D2}", new string('x', 500)))]);
|
||||
var publisher = new RecordingHealthPublisher();
|
||||
var actor = SpawnDriverActor(driver, publisher);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var latest = publisher.Published.LastOrDefault();
|
||||
latest.ShouldNotBeNull();
|
||||
latest!.Count.ShouldBe(DriverDiagnostics.MaxEntries);
|
||||
latest.Values.ShouldAllBe(v => v.Length == DriverDiagnostics.MaxValueLength);
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
private IActorRef SpawnDriverActor(IDriver driver, IDriverHealthPublisher publisher)
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
parent.IgnoreMessages(_ => true);
|
||||
return parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(50)));
|
||||
}
|
||||
|
||||
private sealed class RecordingHealthPublisher : IDriverHealthPublisher
|
||||
{
|
||||
private readonly List<IReadOnlyDictionary<string, string>?> _published = [];
|
||||
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, string>?> Published
|
||||
{
|
||||
get { lock (_published) return _published.ToArray(); }
|
||||
}
|
||||
|
||||
public void Publish(
|
||||
string clusterId,
|
||||
string driverInstanceId,
|
||||
DriverHealth health,
|
||||
int errorCount5Min,
|
||||
DateTime? rediscoveryNeededUtc = null,
|
||||
string? rediscoveryReason = null,
|
||||
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
|
||||
SkippedTagReport? skippedTags = null,
|
||||
IReadOnlyDictionary<string, string>? diagnostics = null)
|
||||
{
|
||||
lock (_published) _published.Add(diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stable-health driver exposing <see cref="IDriverDiagnostics"/>.</summary>
|
||||
private sealed class DiagStubDriver : IDriver, IDriverDiagnostics
|
||||
{
|
||||
private static readonly DateTime FixedLastRead = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc);
|
||||
private volatile IReadOnlyDictionary<string, string> _bag = new Dictionary<string, string>();
|
||||
|
||||
public bool Throw { get; init; }
|
||||
|
||||
public string DriverInstanceId => "diag-stub-1";
|
||||
|
||||
public string DriverType => "Stub";
|
||||
|
||||
public void Set(params (string Key, string Value)[] items) =>
|
||||
_bag = items.ToDictionary(i => i.Key, i => i.Value);
|
||||
|
||||
public IReadOnlyDictionary<string, string> GetDiagnostics() =>
|
||||
Throw ? throw new InvalidOperationException("broken") : _bag;
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user