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; /// /// WP7 unit tests for : dependency-ref extraction from the authored /// tags, the change-gate (no publish until every declared dep has arrived), value dedup, the /// Good→Bad transition + ScriptLogEntry emission + Good recovery, and timer-trigger recompute. /// 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 Published, List Logs) Build(params RawTagEntry[] tags) { var logs = new List(); 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(); 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); } [Fact] public void Health_is_always_Connected() { var (driver, _, _) = Build(CalcTag("calc/e/x", "return 1.0;")); driver.GetHealth().State.ShouldBe(DriverState.Healthy); } private static async Task WaitUntil(Func predicate, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (predicate()) return true; await Task.Delay(20); } return predicate(); } private sealed class CapturingPublisher(List sink) : IScriptLogPublisher { private readonly object _lock = new(); public void Publish(ScriptLogEntry entry) { lock (_lock) sink.Add(entry); } } }