using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Deployment; /// /// Site Runtime S3: the pure deploy-time compile validator. It mirrors the /// compile work /// does at runtime (scripts, on-trigger scripts, Expression triggers) so the /// Deployment Manager can reject a non-compiling deployment before any actor is /// created — WITHOUT touching actor state (safe on a Task.Run thread). /// public class DeployCompileValidatorTests { private static DeployCompileValidator NewValidator() => new(new ScriptCompilationService(NullLogger.Instance)); private static string ConfigWith(string scriptCode) => JsonSerializer.Serialize(new FlattenedConfiguration { Scripts = new List { new() { CanonicalName = "S1", Code = scriptCode, TriggerType = "Call" } }, Attributes = new List(), Alarms = new List(), NativeAlarmSources = new List() }); /// Verifies that broken script, returns errors. [Fact] public void BrokenScript_ReturnsErrors() { var errors = NewValidator().Validate(ConfigWith("this is not C# ~~~")); Assert.NotEmpty(errors); Assert.Contains(errors, e => e.Contains("S1")); } /// Verifies that valid script, returns no errors. [Fact] public void ValidScript_ReturnsNoErrors() { Assert.Empty(NewValidator().Validate(ConfigWith("return 1 + 1;"))); } /// Verifies that broken trigger expression, returns errors. [Fact] public void BrokenTriggerExpression_ReturnsErrors() { // One Expression-triggered script whose trigger expression "1 +" does not compile. var cfg = JsonSerializer.Serialize(new FlattenedConfiguration { Scripts = new List { new() { CanonicalName = "S1", Code = "return 1;", TriggerType = "Expression", TriggerConfiguration = JsonSerializer.Serialize(new { expression = "1 +" }) } }, Attributes = new List(), Alarms = new List(), NativeAlarmSources = new List() }); var errors = NewValidator().Validate(cfg); Assert.NotEmpty(errors); Assert.Contains(errors, e => e.Contains("script-trigger-S1")); } /// Verifies that malformed json, returns single deserialize error. [Fact] public void MalformedJson_ReturnsSingleDeserializeError() { var errors = NewValidator().Validate("{ this is not json"); Assert.Single(errors); Assert.Contains("deserialize", errors[0], StringComparison.OrdinalIgnoreCase); } /// Verifies that missing on trigger script, returns error. [Fact] public void MissingOnTriggerScript_ReturnsError() { var cfg = JsonSerializer.Serialize(new FlattenedConfiguration { Scripts = new List(), Attributes = new List(), Alarms = new List { new() { CanonicalName = "A1", TriggerType = "HiLo", OnTriggerScriptCanonicalName = "DoesNotExist" } }, NativeAlarmSources = new List() }); var errors = NewValidator().Validate(cfg); Assert.Contains(errors, e => e.Contains("A1") && e.Contains("DoesNotExist")); } /// /// Review 2026-10-21 01/SR-C1: names that become Akka actor names are checked by the gate, /// with Akka's own rule (so Pump(A), which Akka accepts, is not rejected). /// [Theory] [InlineData("a b", true)] [InlineData("Furnace #3", true)] [InlineData("Ofen Süd", true)] [InlineData("$Pump", true)] [InlineData("Pump(A)", false)] [InlineData("Line-1_A.B", false)] public void InstanceName_IsCheckedAsAnActorName(string instanceName, bool rejected) { var errors = NewValidator().Validate(ConfigWith("return 1;"), instanceName); Assert.Equal(rejected, errors.Any(DeployCompileValidator.IsActorNameError)); if (rejected) Assert.Contains(errors, e => e.Contains(instanceName)); } /// Script, alarm and native-source canonical names are checked too. [Fact] public void ChildNames_AreCheckedAsActorNames() { var cfg = JsonSerializer.Serialize(new FlattenedConfiguration { Scripts = [new ResolvedScript { CanonicalName = "My Script", Code = "return 1;", TriggerType = "Call" }], Alarms = [new ResolvedAlarm { CanonicalName = "Hi/Temp", TriggerType = "ValueMatch" }], NativeAlarmSources = [new ResolvedNativeAlarmSource { CanonicalName = "Src#1", ConnectionName = "c" }], Attributes = [] }); var nameErrors = NewValidator().Validate(cfg).Where(DeployCompileValidator.IsActorNameError).ToList(); Assert.Equal(3, nameErrors.Count); Assert.Contains(nameErrors, e => e.Contains("My Script")); Assert.Contains(nameErrors, e => e.Contains("Hi/Temp")); Assert.Contains(nameErrors, e => e.Contains("Src#1")); } /// /// Lane A M2: connection names that sanitise to the same DataConnectionActor name are /// rejected by the gate; distinct ones (including non-ASCII) pass. /// [Fact] public void ConnectionNames_CollidingAfterSanitising_AreRejected() { static string Cfg(params string[] names) => JsonSerializer.Serialize(new FlattenedConfiguration { Scripts = [], Alarms = [], NativeAlarmSources = [], Attributes = [], Connections = names.ToDictionary(n => n, _ => new ConnectionConfig { Protocol = "OpcUa" }) }); var collide = NewValidator().Validate(Cfg("PLC A", "PLC-A")).Where(DeployCompileValidator.IsActorNameError).ToList(); Assert.Single(collide); Assert.Contains("PLC A", collide[0]); Assert.Contains("PLC-A", collide[0]); Assert.Empty(NewValidator().Validate(Cfg("PLC A", "Ofen Süd"))); } }