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; /// /// WP7 Calculation deploy gates in : scriptId existence and the /// calc→calc dependency-cycle gate (self-cycle, 2-cycle, and the cross-driver-terminal negative case). /// [Trait("Category", "Unit")] public sealed class DraftValidatorCalculationTests { // A calc DriverInstance "calc" + one Device "engine" → calc tags get RawPath "calc/engine/". 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"); } }