feat(driver-calc): B2-WP7 Calculation driver — IDependencyConsumer + triggers + deploy cycle gate
Adds the Calculation pseudo-driver (tags computed by C# scripts over other tags' live values) plus the host seam that feeds it: - IDependencyConsumer capability interface (Core.Abstractions) + DriverTypeNames.Calculation. - CalculationDriver : IDriver, ISubscribable, IReadable, IDependencyConsumer — change-gate + dedupe (VirtualTagActor parity), timer-group scheduler, Bad-transition + script-log emission, Good recovery; reuses the T0-4 CalculationEvaluator. - DriverHostActor spawns a DependencyConsumerMuxAdapter per dependency-consuming driver, registering its refs on the per-node DependencyMuxActor and forwarding DependencyValueChanged → OnDependencyValue; re-registers on every apply, torn down with the driver. - Factory + probe registered in DriverFactoryBootstrap (keeps the T0-3 guard green); guard test csproj references the driver so the bin-scan discovers the factory. - DeploymentArtifact injects the resolved scriptSource (scriptId → SourceCode) into a calc tag's delivered TagConfig so the host-blind driver can compile it. - DraftValidator deploy gates: CalculationScriptMissing / CalculationScriptNotFound (scriptId existence) + CalculationDependencyCycle (DependencyGraph.DetectCycles over calc→calc edges). - Tests: driver units (dep-ref extraction, change-gate, dedupe, Bad+script-log+recovery, timer, read), tag-config parsing, DraftValidator gates (self/2-cycle/cross-driver-terminal), and a Runtime integration test proving values FLOW mux → adapter → driver → calc-of-calc end-to-end. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user