@@ -0,0 +1,102 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -72,3 +72,30 @@ services:
|
||||
"--tag=F8[120]",
|
||||
"--tag=B3[10]"
|
||||
]
|
||||
|
||||
# PR ablegacy-12 / #255 — faulty-PLC fixture for the auto-demote contract.
|
||||
# FIXTURE-TIER FOLLOW-UP: implementing a refusing-proxy container that
|
||||
# round-trips libplctag's CIP framing far enough to trigger comm failures
|
||||
# (vs. just RST'ing the TCP handshake) is non-trivial — the integration
|
||||
# test currently uses 127.0.0.1:1 (the bogus-port standard) which RST's
|
||||
# immediately on most TCP stacks. That gets us deterministic comm-failure
|
||||
# coverage without standing up a second container; if the localhost:1
|
||||
# trick stops working on a future test runner (e.g. a sandbox that
|
||||
# blocks port 1) re-enable this stub:
|
||||
#
|
||||
# slc500-faulty:
|
||||
# profiles: ["slc500-faulty"]
|
||||
# image: otopcua-ab-server:libplctag-release
|
||||
# build:
|
||||
# context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: otopcua-ab-server-slc500-faulty
|
||||
# restart: "no"
|
||||
# ports:
|
||||
# - "44819:44819"
|
||||
# # Hostile entrypoint: bind the port but exit immediately so subsequent
|
||||
# # connection attempts get RST'd. Future iteration: a libplctag-aware
|
||||
# # proxy that accepts the CIP open and then drops the wire halfway
|
||||
# # through, exercising the read-timeout path rather than the
|
||||
# # connection-refused path.
|
||||
# entrypoint: ["sh", "-c", "exit 1"]
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — auto-demote on consecutive comm failure. After
|
||||
/// <c>FailureThreshold</c> consecutive read or probe failures the driver
|
||||
/// marks the device <c>Demoted</c> for <c>DemoteFor</c>; subsequent reads
|
||||
/// short-circuit with <c>BadCommunicationError</c> without invoking
|
||||
/// libplctag, so one slow PLC sharing the driver thread can't starve faster
|
||||
/// peers. Probe success clears the demote early; read success resets the
|
||||
/// consecutive-failure tally without leaving the demote window.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyAutoDemoteTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
private const string SecondHost = "ab://10.0.0.6/1,0";
|
||||
|
||||
/// <summary>
|
||||
/// Disable the probe by default — every test wants deterministic
|
||||
/// control over the failure tally without a background loop racing
|
||||
/// against the read path.
|
||||
/// </summary>
|
||||
private static AbLegacyDriverOptions BaseOptions(
|
||||
AbLegacyDemoteOptions? demote = null,
|
||||
IReadOnlyList<AbLegacyDeviceOptions>? devices = null,
|
||||
IReadOnlyList<AbLegacyTagDefinition>? tags = null) => new()
|
||||
{
|
||||
Devices = devices ?? [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Demote: demote)],
|
||||
Tags = tags ?? [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
|
||||
AbLegacyDemoteOptions? demote = null,
|
||||
IReadOnlyList<AbLegacyDeviceOptions>? devices = null,
|
||||
IReadOnlyList<AbLegacyTagDefinition>? tags = null)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(BaseOptions(demote, devices, tags), "drv-demote", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
private static FakeAbLegacyTag SeedFailingTag(FakeAbLegacyTagFactory factory)
|
||||
{
|
||||
// Cause every read to throw — exception-driven failures count as
|
||||
// BadCommunicationError per RecordError(commFailure:true).
|
||||
factory.Customise = p => new FakeAbLegacyTag(p)
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new TimeoutException("simulated comm failure"),
|
||||
};
|
||||
// Return value is the prototype so a caller that wants to flip the
|
||||
// failure off later can do so via factory.Tags["N7:0"].
|
||||
return null!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Three_consecutive_failures_demote_the_device()
|
||||
{
|
||||
var (drv, factory) = NewDriver();
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.DemotedUntilUtc.ShouldNotBeNull();
|
||||
var snap = drv.DiagnosticTags.Snapshot(Host);
|
||||
snap.DemoteCount.ShouldBe(1);
|
||||
snap.LastDemotedUtc.ShouldNotBeNull();
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Demoted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_while_demoted_short_circuit_without_invoking_libplctag()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyDemoteOptions(FailureThreshold: 3, DemoteFor: TimeSpan.FromMinutes(5)));
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Trip the demotion.
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
var readsBeforeDemote = factory.Tags["N7:0"].ReadCount;
|
||||
|
||||
// Subsequent reads MUST NOT call into libplctag — the short-circuit
|
||||
// returns BadCommunicationError before EnsureTagRuntimeAsync.
|
||||
var result = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
result[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(readsBeforeDemote);
|
||||
|
||||
var result2 = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
result2[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(readsBeforeDemote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_DemoteFor_expires_next_read_dispatches_through()
|
||||
{
|
||||
// Tiny window so the cool-down expires within the test.
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyDemoteOptions(FailureThreshold: 2, DemoteFor: TimeSpan.FromMilliseconds(50)));
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Trip with two failures.
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.DemotedUntilUtc.ShouldNotBeNull();
|
||||
var readsBeforeWait = factory.Tags["N7:0"].ReadCount;
|
||||
|
||||
// Flip the fake to succeed and wait past the demote window.
|
||||
factory.Tags["N7:0"].ThrowOnRead = false;
|
||||
factory.Tags["N7:0"].Value = 42;
|
||||
factory.Tags["N7:0"].Status = 0;
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(120));
|
||||
|
||||
var result = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
result[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
result[0].Value.ShouldBe(42);
|
||||
// The window expiry path dispatched through to libplctag.
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBeGreaterThan(readsBeforeWait);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_read_resets_consecutive_failure_counter()
|
||||
{
|
||||
var (drv, factory) = NewDriver();
|
||||
// Initial state — every read fails.
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.ConsecutiveFailures.ShouldBe(2);
|
||||
|
||||
// One successful read — flip the existing fake.
|
||||
factory.Tags["N7:0"].ThrowOnRead = false;
|
||||
factory.Tags["N7:0"].Value = 99;
|
||||
factory.Tags["N7:0"].Status = 0;
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
state.ConsecutiveFailures.ShouldBe(0);
|
||||
state.DemotedUntilUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failure_success_failure_does_not_demote_at_threshold_three()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyDemoteOptions(FailureThreshold: 3));
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// 2 failures.
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
// 1 success — counter resets.
|
||||
factory.Tags["N7:0"].ThrowOnRead = false;
|
||||
factory.Tags["N7:0"].Status = 0;
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
// 2 more failures — should still be below the threshold.
|
||||
factory.Tags["N7:0"].ThrowOnRead = true;
|
||||
factory.Tags["N7:0"].Exception = new TimeoutException("flap");
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.DemotedUntilUtc.ShouldBeNull();
|
||||
drv.DiagnosticTags.Snapshot(Host).DemoteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DemoteCount_and_LastDemotedUtc_surface_via_diagnostic_short_circuit()
|
||||
{
|
||||
var (drv, factory) = NewDriver();
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
// Read the synthetic _Diagnostics counters.
|
||||
var demoteCountRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{Host}/DemoteCount";
|
||||
var lastDemotedRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{Host}/LastDemotedUtc";
|
||||
var counts = await drv.ReadAsync([demoteCountRef, lastDemotedRef], CancellationToken.None);
|
||||
|
||||
counts[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
counts[0].Value.ShouldBe(1L);
|
||||
counts[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
counts[1].Value.ShouldBeOfType<string>();
|
||||
((string)counts[1].Value!).Length.ShouldBeGreaterThan(0); // ISO-8601 stamp
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Demote_disabled_never_short_circuits_reads()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyDemoteOptions(FailureThreshold: 1, Enabled: false));
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// 5 failures — would normally trip a single-fail threshold, but Enabled=false.
|
||||
for (var i = 0; i < 5; i++) await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.DemotedUntilUtc.ShouldBeNull();
|
||||
var snap = drv.DiagnosticTags.Snapshot(Host);
|
||||
snap.DemoteCount.ShouldBe(0);
|
||||
// Failures still get recorded as comm errors though — the diagnostic
|
||||
// surface is honest about what happened, just no auto-throttle.
|
||||
snap.CommFailures.ShouldBe(5);
|
||||
// libplctag was invoked every time — that's the whole point of opting out.
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinit_preserves_DemoteCount_but_clears_active_demotion()
|
||||
{
|
||||
var (drv, factory) = NewDriver();
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
drv.DiagnosticTags.Snapshot(Host).DemoteCount.ShouldBe(1);
|
||||
drv.GetDeviceState(Host)!.DemotedUntilUtc.ShouldNotBeNull();
|
||||
|
||||
await drv.ReinitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Active demotion cleared (the device is freshly tracked); cumulative count survives.
|
||||
drv.GetDeviceState(Host)!.DemotedUntilUtc.ShouldBeNull();
|
||||
drv.GetDeviceState(Host)!.ConsecutiveFailures.ShouldBe(0);
|
||||
drv.DiagnosticTags.Snapshot(Host).DemoteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposing_driver_after_demotion_does_not_throw()
|
||||
{
|
||||
var (drv, factory) = NewDriver();
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Demote_options_dto_round_trips_through_factory_extensions()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PlcFamily": "Slc500",
|
||||
"Demote": {
|
||||
"FailureThreshold": 5,
|
||||
"DemoteForMs": 60000,
|
||||
"Enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"Probe": { "Enabled": false },
|
||||
"Tags": [
|
||||
{ "Name": "X", "DeviceHostAddress": "ab://10.0.0.5/1,0", "Address": "N7:0", "DataType": "Int" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var drv = AbLegacyDriverFactoryExtensions.CreateInstance("drv-demote-roundtrip", json);
|
||||
await drv.InitializeAsync(json, CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.Options.Demote.ShouldNotBeNull();
|
||||
state.Options.Demote!.FailureThreshold.ShouldBe(5);
|
||||
state.Options.Demote.EffectiveDemoteFor.ShouldBe(TimeSpan.FromMinutes(1));
|
||||
state.Options.Demote.Enabled.ShouldBeTrue();
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_devices_one_faulty_does_not_starve_the_healthy_one()
|
||||
{
|
||||
// Mixed factory — one host's tag throws, the other's reads cleanly.
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
factory.Customise = p =>
|
||||
{
|
||||
// Identify by the Gateway portion of the create params.
|
||||
var fail = p.Gateway == "10.0.0.6";
|
||||
return new FakeAbLegacyTag(p)
|
||||
{
|
||||
ThrowOnRead = fail,
|
||||
Exception = fail ? new TimeoutException("faulty") : null,
|
||||
Value = 42,
|
||||
Status = 0,
|
||||
};
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500),
|
||||
new AbLegacyDeviceOptions(SecondHost, AbLegacyPlcFamily.Slc500),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Healthy", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Faulty", SecondHost, "N7:0", AbLegacyDataType.Int),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-mix", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Trip the faulty side.
|
||||
for (var i = 0; i < 3; i++)
|
||||
await drv.ReadAsync(["Faulty"], CancellationToken.None);
|
||||
|
||||
// Healthy host MUST keep returning Good even though the sibling is demoted.
|
||||
var healthyResult = await drv.ReadAsync(["Healthy"], CancellationToken.None);
|
||||
healthyResult[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
healthyResult[0].Value.ShouldBe(42);
|
||||
|
||||
// Reads against the faulty host short-circuit.
|
||||
var faultyResult = await drv.ReadAsync(["Faulty"], CancellationToken.None);
|
||||
faultyResult[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
|
||||
drv.GetDeviceState(Host)!.DemotedUntilUtc.ShouldBeNull();
|
||||
drv.GetDeviceState(SecondHost)!.DemotedUntilUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BadNodeIdUnknown_does_not_count_toward_demote_tally()
|
||||
{
|
||||
// -14 maps to BadNodeIdUnknown — terminal, not a comm failure.
|
||||
var (drv, factory) = NewDriver();
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Status = -14 };
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
// Five terminal failures shouldn't trip the demote threshold — they're
|
||||
// a config / decoder mismatch, not a sign of a flapping link.
|
||||
state.DemotedUntilUtc.ShouldBeNull();
|
||||
drv.DiagnosticTags.Snapshot(Host).DemoteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostState_enum_has_Demoted_value()
|
||||
{
|
||||
// Belt-and-braces: the abstraction surface must carry the new value
|
||||
// for downstream consumers (HostStatusPublisher, Admin UI, …) to
|
||||
// see and route it.
|
||||
Enum.IsDefined(typeof(HostState), HostState.Demoted).ShouldBeTrue();
|
||||
((int)HostState.Demoted).ShouldBeGreaterThan((int)HostState.Faulted);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,9 @@ public sealed class AbLegacyDiagnosticsTests
|
||||
var diagVars = builder.Variables
|
||||
.Where(v => v.Info.FullName.StartsWith(AbLegacyDiagnosticTags.DiagnosticsFolderPrefix))
|
||||
.ToList();
|
||||
diagVars.Count.ShouldBe(14); // 7 names × 2 devices
|
||||
// PR ablegacy-12 / #255 — DemoteCount + LastDemotedUtc bring the canonical
|
||||
// count to 9 names per device (was 7 in PR ablegacy-10).
|
||||
diagVars.Count.ShouldBe(AbLegacyDiagnosticTags.DiagnosticTagNames.Count * 2);
|
||||
diagVars.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,14 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
|
||||
internal sealed class FakeAbLegacyTagFactory : IAbLegacyTagFactory
|
||||
{
|
||||
public Dictionary<string, FakeAbLegacyTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
// PR ablegacy-12 / #255 — switched from plain Dictionary to ConcurrentDictionary so
|
||||
// the read path (test thread) and the probe loop (background Task) can both call
|
||||
// Create without corrupting the dict. Pre-PR-12 the race existed but only tipped
|
||||
// a few percent of test runs into KeyNotFoundException; PR-12's added
|
||||
// Interlocked.Exchange writes shifted timing enough to make it deterministic-flaky
|
||||
// (~60%).
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, FakeAbLegacyTag> Tags { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
public Func<AbLegacyTagCreateParams, FakeAbLegacyTag>? Customise { get; set; }
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
|
||||
|
||||
Reference in New Issue
Block a user