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; /// /// WP7 register-AND-consume proof: dependency values actually FLOW end-to-end through the REAL /// → /// path (not merely that 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 ForwardToMux performs, mirrored /// here by looping the driver's back into the mux. /// 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); } /// Build the merged DriverConfig JSON the ApplyDelta path re-binds from (mirrors the deploy /// artifact folding the driver's RawTags into DriverInstanceSpec.DriverConfig). 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(); var mux = Sys.ActorOf(DependencyMuxActor.Props(), "dep-mux"); // Loop the driver's outputs back into the mux exactly as DriverHostActor.ForwardToMux does — this // is what makes calc-of-calc chains work. driver.OnDataChange += (_, e) => { published.Enqueue(e); var quality = e.Snapshot.StatusCode == 0u ? OpcUaQuality.Good : OpcUaQuality.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, OpcUaQuality.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)); } /// /// REDEPLOY proof (review HIGH): a pure DriverConfig change (calc tags added / removed, a script /// edited) routes to an in-place — 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. /// [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(); var mux = Sys.ActorOf(DependencyMuxActor.Props(), "dep-mux2"); driver.OnDataChange += (_, e) => { published.Enqueue(e); var quality = e.Snapshot.StatusCode == 0u ? OpcUaQuality.Good : OpcUaQuality.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, OpcUaQuality.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, OpcUaQuality.Good, Ts)); mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 3.0, OpcUaQuality.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)); } }