Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyAutoDemoteTests.cs
2026-04-26 08:44:53 -04:00

103 lines
4.9 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// PR ablegacy-12 / #255 — wire-level smoke for auto-demote on comm failure.
/// Runs only when ab_server is reachable. Two devices: one healthy (the live
/// ab_server slc500 simulator), one pointed at <c>127.0.0.1:1</c> which
/// refuses every connection. After three consecutive failures the faulty
/// device's reads must short-circuit with <c>BadCommunicationError</c>
/// while the healthy device keeps returning <c>Good</c> — the whole point
/// of the feature: one slow / unreachable PLC sharing the driver thread
/// can't starve faster peers.
/// </summary>
/// <remarks>
/// <para>
/// Build-only by default — the assertion that demotion latency is
/// bounded depends on the ab_server simulator timing out on the faulty
/// port within the per-device timeout. We pin the faulty endpoint at
/// <c>127.0.0.1:1</c> (the bogus-port standard) which RST's the
/// connection immediately on most stacks; environments that whitelist
/// outbound to localhost:1 will see different timing but still trip
/// the threshold within the test budget.
/// </para>
/// <para>
/// The Docker fixture extension (<c>slc500-faulty</c>) noted in the PR
/// plan is a documentation-only placeholder for now — implementing a
/// refusing-proxy container is non-trivial and the localhost:1 trick
/// covers the same surface deterministically.
/// </para>
/// </remarks>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyAutoDemoteTests(AbLegacyServerFixture sim)
{
[AbLegacyFact]
public async Task Two_devices_one_unreachable_does_not_starve_healthy_reads()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var healthy = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}";
// 127.0.0.1:1 is the bogus-port standard — typical Linux/Windows TCP
// stacks RST immediately. The driver still reports it as a comm
// failure (libplctag wraps the failure as a transient throw).
var faulty = "ab://127.0.0.1:1/1,0";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices =
[
new AbLegacyDeviceOptions(healthy, AbLegacyPlcFamily.Slc500,
Timeout: TimeSpan.FromSeconds(5)),
new AbLegacyDeviceOptions(faulty, AbLegacyPlcFamily.Slc500,
// Snappy timeout so the test budget stays short.
Timeout: TimeSpan.FromMilliseconds(500),
Demote: new AbLegacyDemoteOptions(
FailureThreshold: 3,
DemoteFor: TimeSpan.FromSeconds(30))),
],
Tags =
[
new AbLegacyTagDefinition("Healthy", healthy, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Faulty", faulty, "N7:0", AbLegacyDataType.Int),
],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, driverInstanceId: "ablegacy-auto-demote-it");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Trip the demote on the faulty device.
for (var i = 0; i < 3; i++)
{
await drv.ReadAsync(["Faulty"], TestContext.Current.CancellationToken);
}
// Healthy host MUST keep returning Good even though the sibling is demoted.
var healthyResult = await drv.ReadAsync(["Healthy"], TestContext.Current.CancellationToken);
healthyResult[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
// Faulty host now short-circuits without waiting on libplctag's timeout.
var sw = System.Diagnostics.Stopwatch.StartNew();
var faultyResult = await drv.ReadAsync(["Faulty"], TestContext.Current.CancellationToken);
sw.Stop();
faultyResult[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
// Short-circuit should be ~1 ms; pad generously for CI noise. The pre-PR-12
// path would have waited the full 500 ms timeout.
sw.ElapsedMilliseconds.ShouldBeLessThan(200);
// Counter access via the public diagnostic short-circuit path — the
// internal Snapshot() seam isn't visible from this assembly.
var demoteCountRef = $"_Diagnostics/{faulty}/DemoteCount";
var lastDemotedRef = $"_Diagnostics/{faulty}/LastDemotedUtc";
var diag = await drv.ReadAsync(
[demoteCountRef, lastDemotedRef], TestContext.Current.CancellationToken);
((long)diag[0].Value!).ShouldBeGreaterThan(0);
((string)diag[1].Value!).Length.ShouldBeGreaterThan(0);
}
}