09e0fe1841
N1: linkDown is set only when NO reply arrived, i.e. a timeout, a transport exception or a connect failure. A status the CNC returned proves it answered. EW_REJECT and every unlisted rc map to BadCommunicationError, so a deterministic rc on one tag used to fail every later tag of a reachable control on every poll. The comms-classed status still degrades health. The coalescer already treated a returned refusal this way; it is unchanged. Tests: a returned EW_REJECT on tag 1 leaves tags 2 and 3 read and Good (red before). A timeout still fails the rest without reading them (positive control). Minors: - m1: the _lifecycleLock doc now covers the health transitions it also guards. - m2: the NoteFixedTreeFailed summary says a refusal credits the answer. - m3: ReadAsync stamps a failed batch's exception with the generation the batch started under, and HandlePollError degrades that generation only. - m4: the fixed-tree "blip" absence check runs unconditionally under a wide budget, and asserts that both ticks landed inside it. - m5: direct unit cases for the coalescer link-down paths. A transport fault on the first range fails the second range and the per-tag tag without reading them. A connect failure costs one connect per batch. CncRelay.Up() retries the re-bind. Refs #740 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
89 lines
3.8 KiB
C#
89 lines
3.8 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
/// <summary>
|
|
/// 05/STAB-9 — the poll engine's <c>onError</c> sink must be wired to the driver health
|
|
/// surface: a poll-loop reader failure degrades health while preserving
|
|
/// <see cref="DriverHealth.LastSuccessfulRead"/>, and must never downgrade a pre-existing
|
|
/// <see cref="DriverState.Faulted"/>.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasPollErrorHealthTests
|
|
{
|
|
/// <summary>A poll reader failure degrades health, preserving the last successful read timestamp.</summary>
|
|
[Fact]
|
|
public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead()
|
|
{
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, "focas-1", new FakeFocasClientFactory());
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var before = drv.GetHealth();
|
|
before.State.ShouldBe(DriverState.Healthy);
|
|
before.LastSuccessfulRead.ShouldNotBeNull();
|
|
|
|
drv.HandlePollError(new InvalidOperationException("poll boom"));
|
|
|
|
var after = drv.GetHealth();
|
|
after.State.ShouldBe(DriverState.Degraded);
|
|
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
|
|
after.LastError.ShouldNotBeNull();
|
|
after.LastError.ShouldContain("poll boom");
|
|
}
|
|
|
|
/// <summary>A poll reader failure never downgrades a pre-existing Faulted state.</summary>
|
|
[Fact]
|
|
public async Task PollReaderFailure_NeverDowngradesFaulted()
|
|
{
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
// DeviceName "…:9999" is not in Devices → InitializeAsync fails fast → Faulted.
|
|
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte))],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, "focas-1", new FakeFocasClientFactory());
|
|
|
|
await Should.ThrowAsync<Exception>(() => drv.InitializeAsync("{}", CancellationToken.None));
|
|
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
|
|
drv.HandlePollError(new InvalidOperationException("poll boom"));
|
|
|
|
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #740 review round 2 m3 — a poll failure carries the generation its batch STARTED under, so a batch that
|
|
/// began before a re-initialise cannot degrade the new generation. Positive control: a failure stamped with
|
|
/// the live generation (or unstamped) does degrade.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task PollReaderFailure_from_a_previous_generation_does_not_degrade_the_new_one()
|
|
{
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, "focas-1", new FakeFocasClientFactory());
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var oldGeneration = drv.LifecycleGeneration;
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var stale = new InvalidOperationException("old batch boom");
|
|
stale.Data[FocasDriver.BatchGenerationKey] = oldGeneration;
|
|
drv.HandlePollError(stale);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
|
|
var live = new InvalidOperationException("live batch boom");
|
|
live.Data[FocasDriver.BatchGenerationKey] = drv.LifecycleGeneration;
|
|
drv.HandlePollError(live);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
}
|