Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs
T
Joseph Doherty 09e0fe1841 fix(focas): a returned status never ends a device's batch; round-2 minors (#740 review round 2)
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
2026-10-22 14:55:37 -04:00

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);
}
}