merge wt/wp7 into v3/batch2-raw-ui (Wave C — Calculation driver)
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP7 Calculation deploy gates in <see cref="DraftValidator"/>: <c>scriptId</c> existence and the
|
||||
/// calc→calc dependency-cycle gate (self-cycle, 2-cycle, and the cross-driver-terminal negative case).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftValidatorCalculationTests
|
||||
{
|
||||
// A calc DriverInstance "calc" + one Device "engine" → calc tags get RawPath "calc/engine/<name>".
|
||||
private static DriverInstance CalcDriver() => new()
|
||||
{
|
||||
DriverInstanceId = "di-calc", ClusterId = "c", Name = "calc",
|
||||
DriverType = "Calculation", DriverConfig = "{}",
|
||||
};
|
||||
|
||||
private static DriverInstance ModbusDriver() => new()
|
||||
{
|
||||
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
|
||||
DriverType = "Modbus", DriverConfig = "{}",
|
||||
};
|
||||
|
||||
private static Device Device(string id, string driverInstanceId, string name) => new()
|
||||
{
|
||||
DeviceId = id, DriverInstanceId = driverInstanceId, Name = name, DeviceConfig = "{}",
|
||||
};
|
||||
|
||||
private static Tag CalcTagRow(string tagId, string deviceId, string name, string tagConfig) => new()
|
||||
{
|
||||
TagId = tagId, DeviceId = deviceId, Name = name, DataType = "Double",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
|
||||
};
|
||||
|
||||
private static Script ScriptRow(string id, string source) => new()
|
||||
{
|
||||
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h-" + id,
|
||||
};
|
||||
|
||||
private static string Cfg(string scriptId) => $"{{\"scriptId\":\"{scriptId}\",\"changeTriggered\":true}}";
|
||||
|
||||
[Fact]
|
||||
public void Missing_scriptId_is_a_deploy_error()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
DriverInstances = [CalcDriver()],
|
||||
Devices = [Device("dev-1", "di-calc", "engine")],
|
||||
Tags = [CalcTagRow("t-1", "dev-1", "a", "{\"changeTriggered\":true}")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "CalculationScriptMissing" && e.Context == "t-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_scriptId_is_a_deploy_error()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
DriverInstances = [CalcDriver()],
|
||||
Devices = [Device("dev-1", "di-calc", "engine")],
|
||||
Tags = [CalcTagRow("t-1", "dev-1", "a", Cfg("s-nope"))],
|
||||
Scripts = [],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "CalculationScriptNotFound" && e.Context == "t-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Existing_scriptId_with_no_cycle_is_accepted()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
DriverInstances = [CalcDriver()],
|
||||
Devices = [Device("dev-1", "di-calc", "engine")],
|
||||
Tags = [CalcTagRow("t-1", "dev-1", "a", Cfg("s1"))],
|
||||
Scripts = [ScriptRow("s1", "return 42.0;")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code.StartsWith("Calculation"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Self_cycle_is_rejected()
|
||||
{
|
||||
// calc/engine/a reads itself.
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
DriverInstances = [CalcDriver()],
|
||||
Devices = [Device("dev-1", "di-calc", "engine")],
|
||||
Tags = [CalcTagRow("t-a", "dev-1", "a", Cfg("s-a"))],
|
||||
Scripts = [ScriptRow("s-a", "return (double)ctx.GetTag(\"calc/engine/a\").Value;")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e =>
|
||||
e.Code == "CalculationDependencyCycle" && e.Message.Contains("calc/engine/a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_node_cycle_names_both_members()
|
||||
{
|
||||
// a → b and b → a.
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
DriverInstances = [CalcDriver()],
|
||||
Devices = [Device("dev-1", "di-calc", "engine")],
|
||||
Tags =
|
||||
[
|
||||
CalcTagRow("t-a", "dev-1", "a", Cfg("s-a")),
|
||||
CalcTagRow("t-b", "dev-1", "b", Cfg("s-b")),
|
||||
],
|
||||
Scripts =
|
||||
[
|
||||
ScriptRow("s-a", "return (double)ctx.GetTag(\"calc/engine/b\").Value;"),
|
||||
ScriptRow("s-b", "return (double)ctx.GetTag(\"calc/engine/a\").Value;"),
|
||||
],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "CalculationDependencyCycle");
|
||||
var msg = DraftValidator.Validate(draft).First(e => e.Code == "CalculationDependencyCycle").Message;
|
||||
msg.ShouldContain("calc/engine/a");
|
||||
msg.ShouldContain("calc/engine/b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cross_driver_ref_is_terminal_not_an_edge()
|
||||
{
|
||||
// A calc tag reads a Modbus tag's RawPath ("mb/plc/reg"); that is a terminal node, never an edge,
|
||||
// so it can never form a cycle — even if the Modbus tag existed.
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
DriverInstances = [CalcDriver(), ModbusDriver()],
|
||||
Devices = [Device("dev-1", "di-calc", "engine"), Device("dev-2", "di-mb", "plc")],
|
||||
Tags =
|
||||
[
|
||||
CalcTagRow("t-a", "dev-1", "a", Cfg("s-a")),
|
||||
new Tag { TagId = "t-reg", DeviceId = "dev-2", Name = "reg", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = "{}" },
|
||||
],
|
||||
Scripts = [ScriptRow("s-a", "return (double)ctx.GetTag(\"mb/plc/reg\").Value;")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "CalculationDependencyCycle");
|
||||
}
|
||||
}
|
||||
+1
@@ -33,6 +33,7 @@
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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);
|
||||
}
|
||||
|
||||
[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<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();
|
||||
}
|
||||
|
||||
private sealed class CapturingPublisher(List<ScriptLogEntry> sink) : IScriptLogPublisher
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
public void Publish(ScriptLogEntry entry)
|
||||
{
|
||||
lock (_lock) sink.Add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||
|
||||
/// <summary>Parsing contract for <see cref="CalculationTagDefinition.TryFromTagConfig"/> — the calc tag's
|
||||
/// <c>TagConfig</c> blob (<c>scriptId</c> + resolved <c>scriptSource</c> + trigger fields) → typed def with
|
||||
/// its static dependency refs.</summary>
|
||||
public sealed class CalculationTagDefinitionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Missing_scriptId_is_not_a_calc_tag()
|
||||
{
|
||||
CalculationTagDefinition.TryFromTagConfig("{\"changeTriggered\":true}", "calc/e/x", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_json_is_a_miss()
|
||||
{
|
||||
CalculationTagDefinition.TryFromTagConfig("not json", "calc/e/x", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_scriptId_source_triggers_and_extracts_dependency_refs()
|
||||
{
|
||||
var json = "{\"scriptId\":\"s1\",\"changeTriggered\":true,\"timerIntervalMs\":5000," +
|
||||
"\"scriptSource\":\"return (double)ctx.GetTag(\\\"src/a\\\").Value + (double)ctx.GetTag(\\\"src/b\\\").Value;\"}";
|
||||
CalculationTagDefinition.TryFromTagConfig(json, "calc/e/x", out var def).ShouldBeTrue();
|
||||
|
||||
def.RawPath.ShouldBe("calc/e/x");
|
||||
def.ScriptId.ShouldBe("s1");
|
||||
def.ChangeTriggered.ShouldBeTrue();
|
||||
def.TimerInterval.ShouldBe(TimeSpan.FromMilliseconds(5000));
|
||||
def.DependencyRefs.ShouldBe(new[] { "src/a", "src/b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeTriggered_defaults_true_and_timer_is_optional()
|
||||
{
|
||||
CalculationTagDefinition.TryFromTagConfig(
|
||||
"{\"scriptId\":\"s1\",\"scriptSource\":\"return 1.0;\"}", "calc/e/x", out var def).ShouldBeTrue();
|
||||
def.ChangeTriggered.ShouldBeTrue();
|
||||
def.TimerInterval.ShouldBeNull();
|
||||
def.DependencyRefs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Present_scriptId_with_empty_source_still_maps()
|
||||
{
|
||||
CalculationTagDefinition.TryFromTagConfig(
|
||||
"{\"scriptId\":\"s1\",\"changeTriggered\":false}", "calc/e/x", out var def).ShouldBeTrue();
|
||||
def.ScriptSource.ShouldBe("");
|
||||
def.ChangeTriggered.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
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>ForwardToMux</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);
|
||||
}
|
||||
|
||||
[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.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));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<!-- WP7: the real Calculation driver, so the dependency-flow integration test drives values through
|
||||
the real DependencyMuxActor → DependencyConsumerMuxAdapter → CalculationDriver path. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user