Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs
T
Joseph Doherty 44b99a9939 fix(runtime,opcuaserver): review fast-follows — event-filter throw test, loud MapKey fallback, log shape
Accumulated fast-follows from the Task 1-11 reviews. No behaviour change on the
value path.

- Strengthen OpcUaPublishActorBatchTests.Sink_throw_is_swallowed_and_logged. The
  survival assertion alone did NOT prove the catch exists: Akka's default
  supervision RESTARTS a throwing actor and the restart preserves the mailbox, so
  the follow-up message landed either way and the test passed against a build with
  no catch at all. It now asserts via EventFilter that the exception NEVER reaches
  supervision (Expect(0) on the InvalidOperationException) and that the handler's
  own Warning IS logged, keeping the survival assertion. Everything that must have
  happened goes inside the Expect(0) action — it counts matches at the end of the
  action, so an assertion left outside would race the actor and pass vacuously.
  Verified falsifiable: deleting the catch turns the test red.

- MapKey(NodeId): the non-string identifier branch collapsed to a string.Empty key
  silently. Such a key cannot match anything the manager stored, so the caller sees
  an unexplained miss rather than an error. It now logs a Utils.LogWarning naming
  the seam. Every current call site is provably string-only, so this should never
  fire.

- The two WireAlarmNotifiers Utils.LogInfo sites interpolated the RealmKey struct,
  rendering a raw struct dump. Interpolate .Id and .Realm as separate placeholders
  so log-grep patterns stay consistent with the file's other sites.

- Stale comments: the _eventNotifierSources key is a RealmKey now, not a
  namespace-qualified NodeId string; and three comments still named
  DriverHostActor.ForwardToMux, which Task 8 replaced with ForwardBatchToMux.
2026-08-15 13:10:43 -04:00

168 lines
9.4 KiB
C#

using System.Collections.Concurrent;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// WP7 register-AND-consume proof: dependency values actually FLOW end-to-end through the REAL
/// <see cref="DependencyMuxActor"/> → <see cref="DependencyConsumerMuxAdapter"/> →
/// <see cref="CalculationDriver"/> path (not merely that <see cref="IDependencyConsumer"/> exists).
/// A source value published to the mux is fed into the calc driver, which computes and publishes a
/// value; and a calc tag reading ANOTHER calc tag's output (calc-of-calc) recomputes when the first
/// tag's output re-enters the mux — the exact re-entry the host's <c>ForwardBatchToMux</c> performs, mirrored
/// here by looping the driver's <see cref="ISubscribable.OnDataChange"/> back into the mux.
/// </summary>
public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
{
private static readonly DateTime Ts = new(2026, 7, 16, 10, 0, 0, DateTimeKind.Utc);
private static RawTagEntry CalcTag(string rawPath, string source)
{
var json = $"{{\"scriptId\":\"s-{rawPath}\",\"changeTriggered\":true," +
$"\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
}
/// <summary>Build the merged DriverConfig JSON the ApplyDelta path re-binds from (mirrors the deploy
/// artifact folding the driver's RawTags into DriverInstanceSpec.DriverConfig).</summary>
private static string ConfigJson(params RawTagEntry[] tags) =>
System.Text.Json.JsonSerializer.Serialize(new { rawTags = tags });
[Fact]
public async Task Source_value_flows_through_mux_and_adapter_into_the_calc_driver_and_propagates_calc_of_calc()
{
// A: out = src/a * 2. B: b = A.out + 1 (calc-of-calc; B depends on A's RawPath).
var driver = new CalculationDriver(
new CalculationDriverOptions
{
RawTags =
[
CalcTag("calc/engine/out", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"),
CalcTag("calc/engine/b", "return (double)ctx.GetTag(\"calc/engine/out\").Value + 1.0;"),
],
},
"calc-drv");
await driver.InitializeAsync("{}", CancellationToken.None);
// The driver declares interest in both the external source AND the first calc tag's output.
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "calc/engine/out", "src/a" });
var published = new ConcurrentQueue<DataChangeEventArgs>();
var mux = Sys.ActorOf(DependencyMuxActor.Props(), "dep-mux");
// Loop the driver's outputs back into the mux exactly as DriverHostActor.ForwardBatchToMux does — this
// is what makes calc-of-calc chains work.
driver.OnDataChange += (_, e) =>
{
published.Enqueue(e);
var quality = e.Snapshot.StatusCode == 0u ? OpcUaStatus.Good : OpcUaStatus.Bad;
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
"calc-drv", e.FullReference, e.Snapshot.Value, quality, Ts));
};
// The REAL adapter registers the driver's dependency refs on the mux + forwards changes into it.
Sys.ActorOf(DependencyConsumerMuxAdapter.Props(driver, mux), "dep-adapter");
// Publish the external source into the mux. AwaitAssert re-Tells (idempotent after the first
// compute dedups) until the computed value has flowed all the way through.
AwaitAssert(() =>
{
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
"src-drv", "src/a", 21.0, OpcUaStatus.Good, Ts));
// Leg 1 — dep flow: src/a=21 → A computes 42 and publishes on calc/engine/out.
published.ShouldContain(e => e.FullReference == "calc/engine/out" && Equals(e.Snapshot.Value, 42.0));
// Leg 2 — calc-of-calc: A's 42 re-entered the mux → B computes 43 on calc/engine/b.
published.ShouldContain(e => e.FullReference == "calc/engine/b" && Equals(e.Snapshot.Value, 43.0));
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100));
}
/// <summary>
/// REDEPLOY proof (review HIGH): a pure DriverConfig change (calc tags added / removed, a script
/// edited) routes to an in-place <see cref="CalculationDriver.ReinitializeAsync"/> — NOT a respawn. The
/// driver must rebuild its tag table + dependency-ref set so the new authored set actually takes effect,
/// and the mux adapter must re-register the NEW refs AFTER reinit. This asserts all four failure modes
/// the pre-fix code exhibited: an added tag reading a NEW dep computes; a removed tag stops publishing;
/// an edited script serves the NEW result; and the changed dependency-ref set re-registers on the mux.
/// </summary>
[Fact]
public async Task Redeploy_rebuilds_calc_tag_state_and_reregisters_new_dependency_refs()
{
// Initial authored set: "keep" = src/a*10 (script gets EDITED), "remove" = src/a+5 (gets REMOVED).
var driver = new CalculationDriver(
new CalculationDriverOptions
{
RawTags =
[
CalcTag("calc/keep", "return (double)ctx.GetTag(\"src/a\").Value * 10.0;"),
CalcTag("calc/remove", "return (double)ctx.GetTag(\"src/a\").Value + 5.0;"),
],
},
"calc-drv");
await driver.InitializeAsync("{}", CancellationToken.None);
// Initially the driver only cares about src/a.
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a" });
var published = new ConcurrentQueue<DataChangeEventArgs>();
var mux = Sys.ActorOf(DependencyMuxActor.Props(), "dep-mux2");
driver.OnDataChange += (_, e) =>
{
published.Enqueue(e);
var quality = e.Snapshot.StatusCode == 0u ? OpcUaStatus.Good : OpcUaStatus.Bad;
mux.Tell(new DriverInstanceActor.AttributeValuePublished("calc-drv", e.FullReference, e.Snapshot.Value, quality, Ts));
};
var adapter = Sys.ActorOf(DependencyConsumerMuxAdapter.Props(driver, mux), "dep-adapter2");
// Leg 1 — before redeploy: src/a=2 → keep=20, remove=7.
AwaitAssert(() =>
{
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 2.0, OpcUaStatus.Good, Ts));
published.ShouldContain(e => e.FullReference == "calc/keep" && Equals(e.Snapshot.Value, 20.0));
published.ShouldContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 7.0));
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100));
// ---- REDEPLOY (in-place ApplyDelta) ----
// New authored set: "keep" script EDITED to src/a*1000; "add" is a NEW tag reading a NEW dep src/b;
// "remove" is dropped entirely.
var newConfig = ConfigJson(
CalcTag("calc/keep", "return (double)ctx.GetTag(\"src/a\").Value * 1000.0;"),
CalcTag("calc/add", "return (double)ctx.GetTag(\"src/b\").Value + 100.0;"));
await driver.ReinitializeAsync(newConfig, CancellationToken.None);
// The rebuilt dependency-ref set reflects the NEW authored tags (src/a from keep, src/b from add).
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b" });
// Part 2 sequencing: the host re-registers the adapter AFTER reinit (DeltaApplied → HandleDeltaApplied).
// Mirror that here — re-register so the adapter adopts the NEW ref set (incl. the brand-new src/b).
adapter.Tell(new DependencyConsumerMuxAdapter.ReRegister());
// Leg 2 — after redeploy: the NEW dep src/b flows + the NEW tag computes; the EDITED script serves the
// new result; the REMOVED tag never publishes a value derived from the post-redeploy input.
AwaitAssert(() =>
{
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/b", 50.0, OpcUaStatus.Good, Ts));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 3.0, OpcUaStatus.Good, Ts));
// (a) new dep B flows through the RE-REGISTERED interest → new tag computes: 50 + 100 = 150.
published.ShouldContain(e => e.FullReference == "calc/add" && Equals(e.Snapshot.Value, 150.0));
// (c) edited script produces the NEW result: 3 * 1000 = 3000 (NOT the pre-edit 3 * 10 = 30).
published.ShouldContain(e => e.FullReference == "calc/keep" && Equals(e.Snapshot.Value, 3000.0));
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100));
// (b) the removed tag never recomputes on the post-redeploy src/a=3 (it would be 3 + 5 = 8) and the
// edited "keep" never serves the stale *10 result (3 * 10 = 30). Give any errant compute a moment to land.
await Task.Delay(200);
published.ShouldNotContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 8.0));
published.ShouldNotContain(e => e.FullReference == "calc/keep" && Equals(e.Snapshot.Value, 30.0));
}
}