2d20aeb779
#519 — a driver's OPC UA status code now reaches a client with its sub-code intact. DriverInstanceActor projected the driver's uint onto a 3-state Good/Uncertain/Bad enum and OtOpcUaNodeManager re-expanded Bad to a bare 0x80000000, so BadNoCommunication, BadTypeMismatch and BadNotSupported were indistinguishable on the wire and every driver's status constants (incl. the 16 #497 had just corrected) were diagnostic in logs only. Resolved by option (b): the raw uint rides AttributeValuePublished -> AttributeValueUpdate -> IOpcUaAddressSpaceSink.WriteValue, OpcUaQuality is deleted, and severity classification is a helper (OpcUaStatus.IsGood/IsUncertain/IsBad). The surface was wider than the issue costed (41 files, not "the sink and 3 implementations"): OpcUaQuality had non-driver producers with no uint to carry. Each got its natural code — BadInternalError for a failed virtual-tag script, BadNoCommunication for driver comms loss — and ScriptedAlarmHostActor's pair of mutually-inverse uint<->enum mappers is deleted, having round-tripped a code the engine already held. AddressSpaceApplier's removal writes deliberately keep the generic Bad: not losing a code the driver had is this issue; inventing one for the deploy path is a separate call. Behaviour change: WriteAlarmQuality fires its Part 9 event on a status-code change rather than a severity-bucket change. While the annotation was 3-state those were the same predicate; now a move between two Bad reasons changes what a subscriber reads. New SDK-free named constants live on Commons.OpcUa.OpcUaStatus, and StatusCodeParityTests is widened to cover them — that guard is reflection-driven, so it can only see a named constant, never an inline literal. #517 — the symptom is real but the issue named the wrong path and its fix was unsafe. ShutdownAsync cannot race: DriverInstanceActor calls it from PostStop, after the mailbox drains. (Core/Hosting/DriverHost, the one class with a concurrent GetHealth, has no production call site.) InitializeAsync can: it runs on a fire-and-forget Task.Run, and MTConnectDriver writes Healthy then starts loops that can throw into a catch which disposes the client before writing Faulted. Moving the write earlier, as suggested, inverts the terminal state — teardown drains in-flight loops that write health as they unwind, so a late loop could leave Healthy at rest with every connection closed. The fix is a bracket (conservative before, terminal after), applied to MTConnect's init-failure path only; shutdown keeps its single trailing write, and the rule plus the reachability table are documented on IDriver.ShutdownAsync. CalculationDriver.GetHealth returned a hard-coded Healthy that consulted nothing — Healthy before initialize, after shutdown and after dispose. Now derived from _running. Its test, named Health_is_always_Connected, asserted the defect and is replaced. Every new guard was falsified by breaking the production code and watching it go red. The two sub-code tests each assert two different Bad codes, since a single-code assertion could pass against a hard-coded constant under the old projection. Pre-existing failures, verified unchanged on a stashed tree and not chased: 4 in Driver.AbLegacy.IntegrationTests (slc500/micrologix/plc5 fixtures not up) and 3 in Host.IntegrationTests. Closes #517 Closes #519 Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
221 lines
11 KiB
C#
221 lines
11 KiB
C#
using System.Collections.Concurrent;
|
|
using Serilog;
|
|
using Serilog.Events;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
|
|
|
/// <summary>
|
|
/// WP7 unit tests for <see cref="CalculationDriver"/>: dependency-ref extraction from the authored
|
|
/// tags, the change-gate (no publish until every declared dep has arrived), value dedup, the
|
|
/// Good→Bad transition + <c>ScriptLogEntry</c> emission + Good recovery, and timer-trigger recompute.
|
|
/// </summary>
|
|
public sealed class CalculationDriverTests
|
|
{
|
|
private const uint StatusGood = 0u;
|
|
private const uint StatusBadInternalError = 0x80020000u;
|
|
private const uint StatusBadWaitingForInitialData = 0x80320000u;
|
|
|
|
private static RawTagEntry CalcTag(string rawPath, string source, bool changeTriggered = true, int? timerMs = null)
|
|
{
|
|
var timer = timerMs is { } ms ? $",\"timerIntervalMs\":{ms}" : "";
|
|
var json = $"{{\"scriptId\":\"s-{rawPath}\",\"changeTriggered\":{(changeTriggered ? "true" : "false")}{timer}," +
|
|
$"\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
|
|
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
|
|
}
|
|
|
|
private static (CalculationDriver Driver, ConcurrentQueue<DataChangeEventArgs> Published, List<ScriptLogEntry> Logs)
|
|
Build(params RawTagEntry[] tags)
|
|
{
|
|
var logs = new List<ScriptLogEntry>();
|
|
var sink = new ScriptLogTopicSink(new CapturingPublisher(logs), LogEventLevel.Information);
|
|
var scriptRoot = new ScriptRootLogger(new LoggerConfiguration().WriteTo.Sink(sink).CreateLogger());
|
|
var driver = new CalculationDriver(new CalculationDriverOptions { RawTags = tags }, "calc-drv", scriptRoot);
|
|
var published = new ConcurrentQueue<DataChangeEventArgs>();
|
|
driver.OnDataChange += (_, e) => published.Enqueue(e);
|
|
return (driver, published, logs);
|
|
}
|
|
|
|
// ── dependency-ref extraction ─────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void DependencyRefs_are_the_union_of_authored_tag_scripts()
|
|
{
|
|
var (driver, _, _) = Build(
|
|
CalcTag("calc/e/x", "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;"),
|
|
CalcTag("calc/e/y", "return (double)ctx.GetTag(\"src/b\").Value + (double)ctx.GetTag(\"src/c\").Value;"));
|
|
|
|
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b", "src/c" });
|
|
}
|
|
|
|
// ── change-gate ───────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task No_publish_until_every_declared_dep_has_arrived()
|
|
{
|
|
var (driver, published, _) = Build(
|
|
CalcTag("calc/e/sum", "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;"));
|
|
await driver.InitializeAsync("{}", default);
|
|
|
|
// Only one of two deps arrived → the change-gate holds.
|
|
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
|
|
published.ShouldBeEmpty();
|
|
|
|
// Second dep arrives → now it computes + publishes 30.
|
|
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var e).ShouldBeTrue();
|
|
e!.FullReference.ShouldBe("calc/e/sum");
|
|
e.Snapshot.Value.ShouldBe(30.0);
|
|
e.Snapshot.StatusCode.ShouldBe(StatusGood);
|
|
}
|
|
|
|
// ── dedup + recompute ─────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Equal_result_is_deduped_and_a_changed_result_republishes()
|
|
{
|
|
var (driver, published, _) = Build(
|
|
CalcTag("calc/e/dbl", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
|
|
await driver.InitializeAsync("{}", default);
|
|
|
|
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow); // → 10 (publish)
|
|
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow); // → 10 (dedup, no publish)
|
|
driver.OnDependencyValue("src/a", 7.0, StatusGood, DateTime.UtcNow); // → 14 (publish)
|
|
|
|
published.Select(p => p.Snapshot.Value).ShouldBe(new object?[] { 10.0, 14.0 });
|
|
}
|
|
|
|
// ── Bad transition + script-log + recovery ────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Failure_publishes_Bad_once_per_transition_emits_script_log_and_recovers()
|
|
{
|
|
var (driver, published, logs) = Build(
|
|
CalcTag("calc/e/div", "return 100 / (int)ctx.GetTag(\"src/a\").Value;"));
|
|
await driver.InitializeAsync("{}", default);
|
|
|
|
// a = 0 → DivideByZero → Failure → Bad (carrying last-known = null) + one script-log.
|
|
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var bad).ShouldBeTrue();
|
|
bad!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
|
|
logs.Count.ShouldBe(1);
|
|
logs[0].Level.ShouldBe("Warning");
|
|
logs[0].ScriptId.ShouldBe("s-calc/e/div");
|
|
logs[0].VirtualTagId.ShouldBe("calc/e/div");
|
|
|
|
// a = 0 again → still failing, but NOT a fresh transition → no second Bad publish (script-log still emits).
|
|
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
|
|
published.ShouldBeEmpty();
|
|
logs.Count.ShouldBe(2);
|
|
|
|
// a = 5 → 20 → Good recovery is force-published.
|
|
driver.OnDependencyValue("src/a", 5, StatusGood, DateTime.UtcNow);
|
|
published.Count.ShouldBe(1);
|
|
published.TryDequeue(out var good).ShouldBeTrue();
|
|
good!.Snapshot.Value.ShouldBe(20);
|
|
good.Snapshot.StatusCode.ShouldBe(StatusGood);
|
|
}
|
|
|
|
// ── timer trigger ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Timer_only_tag_computes_on_its_interval()
|
|
{
|
|
// No dependencies (constant script) so only the timer can drive it.
|
|
var (driver, published, _) = Build(
|
|
CalcTag("calc/e/const", "return 42.0;", changeTriggered: false, timerMs: 50));
|
|
await driver.InitializeAsync("{}", default);
|
|
|
|
var fired = await WaitUntil(() => !published.IsEmpty, TimeSpan.FromSeconds(3));
|
|
fired.ShouldBeTrue("the timer trigger should have computed the tag within 3s");
|
|
published.TryDequeue(out var e).ShouldBeTrue();
|
|
e!.FullReference.ShouldBe("calc/e/const");
|
|
e.Snapshot.Value.ShouldBe(42.0);
|
|
|
|
await driver.ShutdownAsync(default);
|
|
}
|
|
|
|
// ── IReadable ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Read_returns_BadWaitingForInitialData_before_first_eval_then_the_computed_value()
|
|
{
|
|
var (driver, _, _) = Build(CalcTag("calc/e/x", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
|
|
await driver.InitializeAsync("{}", default);
|
|
|
|
var before = await driver.ReadAsync(new[] { "calc/e/x" }, default);
|
|
before[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData);
|
|
|
|
driver.OnDependencyValue("src/a", 3.0, StatusGood, DateTime.UtcNow);
|
|
var after = await driver.ReadAsync(new[] { "calc/e/x" }, default);
|
|
after[0].StatusCode.ShouldBe(StatusGood);
|
|
after[0].Value.ShouldBe(6.0);
|
|
}
|
|
|
|
// REMOVED (Gitea #517): `Health_is_always_Connected` asserted that a driver which had never been
|
|
// initialized still reported Healthy — a characterization test of the defect, not of a requirement.
|
|
// Its valid half (a running Calculation driver has no transport to be Degraded about, so it is simply
|
|
// Healthy) is covered by GetHealth_tracks_the_lifecycle_rather_than_reporting_a_constant below.
|
|
|
|
private static async Task<bool> WaitUntil(Func<bool> predicate, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (predicate()) return true;
|
|
await Task.Delay(20);
|
|
}
|
|
return predicate();
|
|
}
|
|
|
|
// ── health lifecycle (Gitea #517) ─────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Gitea #517 — <c>GetHealth()</c> must not report Healthy for a driver that is not running.
|
|
/// <para>This driver returned a hard-coded <see cref="DriverState.Healthy"/> that consulted
|
|
/// nothing, which made it the fleet's only driver reporting Healthy with its timers stopped —
|
|
/// deterministically, not as a race window. Every other driver reads a <c>_health</c> field that
|
|
/// shutdown lands on <see cref="DriverState.Unknown"/>.</para>
|
|
/// <para>The pre-initialize leg is what makes this falsifiable against the old code: a test that
|
|
/// only checked post-shutdown could be satisfied by a driver that happened to write Unknown once,
|
|
/// whereas asserting BOTH ends pins the value to the lifecycle flag.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetHealth_tracks_the_lifecycle_rather_than_reporting_a_constant()
|
|
{
|
|
var (driver, _, _) = Build(CalcTag("calc/e/x", "return 1.0;"));
|
|
|
|
// Constructed but never initialized — nothing is computing, so Healthy would be a lie.
|
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
|
|
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown,
|
|
"timers are stopped and _running is false — the driver computes nothing");
|
|
|
|
// Re-initialize brings it back, so the flag is genuinely read each call rather than latched once.
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
|
|
driver.Dispose();
|
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
}
|
|
|
|
private sealed class CapturingPublisher(List<ScriptLogEntry> sink) : IScriptLogPublisher
|
|
{
|
|
private readonly object _lock = new();
|
|
public void Publish(ScriptLogEntry entry)
|
|
{
|
|
lock (_lock) sink.Add(entry);
|
|
}
|
|
}
|
|
}
|