243 lines
9.9 KiB
C#
243 lines
9.9 KiB
C#
using System.Linq;
|
|
using System.Text.Json;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Byte-parity tests for the scripted-alarm deployment-artifact decode path
|
|
/// (<c>DeploymentArtifact.BuildEquipmentScriptedAlarmPlans</c>) against the live compose seam
|
|
/// (<c>Phase7Composer.Compose</c>). Both sides derive <c>EquipmentScriptedAlarmPlan</c> from the
|
|
/// same ScriptedAlarm + Script data via the shared <c>EquipmentScriptPaths.ExtractAlarmDependencyRefs</c>
|
|
/// helper, so the decoded plans must equal the composer's element-wise (the record has value
|
|
/// equality including DependencyRefs order). Mirrors the existing EquipmentVirtualTags parity
|
|
/// style in <c>DeploymentArtifactEquipTokenTests</c>.
|
|
/// </summary>
|
|
public sealed class DeploymentArtifactScriptedAlarmParityTests
|
|
{
|
|
private static byte[] BlobOf(object snapshot) => JsonSerializer.SerializeToUtf8Bytes(snapshot);
|
|
|
|
/// <summary>The live composer's plan for the same scripted alarms + scripts the artifact carries
|
|
/// must equal the artifact-decode plan, field-for-field including DependencyRefs order.</summary>
|
|
[Fact]
|
|
public void ParseComposition_scripted_alarms_are_byte_parity_with_composer()
|
|
{
|
|
// Two equipments, each with a scripted alarm: predicate reads a tag via ctx.GetTag("X.Y"),
|
|
// and the message template carries a distinct {A.B} token — so DependencyRefs exercise the
|
|
// predicate-reads-then-template-tokens merge order on both sides.
|
|
var script1 = new Script
|
|
{
|
|
ScriptId = "s-1",
|
|
Name = "hot-1",
|
|
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;",
|
|
SourceHash = "h1",
|
|
};
|
|
var script2 = new Script
|
|
{
|
|
ScriptId = "s-2",
|
|
Name = "hot-2",
|
|
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach2.Temp\").Value) > 80;",
|
|
SourceHash = "h2",
|
|
};
|
|
var alarm1 = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmId = "al-1",
|
|
EquipmentId = "eq-1",
|
|
Name = "Overheat-1",
|
|
AlarmType = "LimitAlarm",
|
|
Severity = 700,
|
|
MessageTemplate = "Machine 1 at {Mach1.Pressure}",
|
|
PredicateScriptId = "s-1",
|
|
HistorizeToAveva = true,
|
|
Retain = true,
|
|
Enabled = true,
|
|
};
|
|
var alarm2 = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmId = "al-2",
|
|
EquipmentId = "eq-2",
|
|
Name = "Overheat-2",
|
|
AlarmType = "OffNormalAlarm",
|
|
Severity = 250,
|
|
MessageTemplate = "Machine 2 at {Mach2.Pressure}",
|
|
PredicateScriptId = "s-2",
|
|
HistorizeToAveva = false,
|
|
Retain = false,
|
|
Enabled = false,
|
|
};
|
|
|
|
var composed = Phase7Composer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
|
Array.Empty<DriverInstance>(), new[] { alarm1, alarm2 },
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
|
virtualTags: Array.Empty<VirtualTag>(),
|
|
scripts: new[] { script1, script2 });
|
|
|
|
// The artifact blob the write side (ConfigComposer) emits: the FULL ScriptedAlarm + Script
|
|
// entities serialised Pascal-case off the EF entity.
|
|
var blob = BlobOf(new
|
|
{
|
|
ScriptedAlarms = new[]
|
|
{
|
|
ToSnapshot(alarm1),
|
|
ToSnapshot(alarm2),
|
|
},
|
|
Scripts = new[]
|
|
{
|
|
new { ScriptId = script1.ScriptId, SourceCode = script1.SourceCode },
|
|
new { ScriptId = script2.ScriptId, SourceCode = script2.SourceCode },
|
|
},
|
|
});
|
|
|
|
var decoded = DeploymentArtifact.ParseComposition(blob);
|
|
|
|
// Byte-parity: element-wise value equality (record equality compares DependencyRefs order).
|
|
decoded.EquipmentScriptedAlarms.Count.ShouldBe(2);
|
|
decoded.EquipmentScriptedAlarms.SequenceEqual(composed.EquipmentScriptedAlarms).ShouldBeTrue();
|
|
// And spot-check the merged DependencyRefs (predicate read first, then template token).
|
|
var plan1 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1");
|
|
plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp", "Mach1.Pressure" });
|
|
plan1.Enabled.ShouldBeTrue();
|
|
var plan2 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2");
|
|
plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp", "Mach2.Pressure" });
|
|
plan2.Enabled.ShouldBeFalse();
|
|
plan2.HistorizeToAveva.ShouldBeFalse();
|
|
plan2.Retain.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>The cluster-scoped overload keeps only the scripted alarms whose EquipmentId belongs to
|
|
/// an in-cluster driver (mirroring how EquipmentVirtualTags + EquipmentTags are filtered).</summary>
|
|
[Fact]
|
|
public void ParseComposition_scoped_keeps_only_my_clusters_scripted_alarms()
|
|
{
|
|
var blob = BlobOf(new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
|
|
Nodes = new[]
|
|
{
|
|
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
|
|
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
|
|
new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
|
|
},
|
|
Equipment = new[]
|
|
{
|
|
new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" },
|
|
new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" },
|
|
},
|
|
Scripts = new[]
|
|
{
|
|
new { ScriptId = "scr", SourceCode = "return ctx.GetTag(\"A.X\").Value;" },
|
|
},
|
|
ScriptedAlarms = new[]
|
|
{
|
|
NewAlarmSnapshot("al-main", "eq-main", "scr"),
|
|
NewAlarmSnapshot("al-sa", "eq-sa", "scr"),
|
|
},
|
|
});
|
|
|
|
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
|
|
main.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-main" });
|
|
|
|
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
|
|
siteA.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-sa" });
|
|
}
|
|
|
|
/// <summary>An alarm whose PredicateScriptId matches no Script row is SKIPPED on BOTH the composer
|
|
/// and the artifact-decode side, so parity holds for the surviving alarm.</summary>
|
|
[Fact]
|
|
public void ParseComposition_skips_alarm_with_missing_predicate_script_matching_composer()
|
|
{
|
|
var goodScript = new Script
|
|
{
|
|
ScriptId = "s-ok",
|
|
Name = "ok",
|
|
SourceCode = "return ctx.GetTag(\"A.X\").Value;",
|
|
SourceHash = "h1",
|
|
};
|
|
var goodAlarm = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmId = "al-ok",
|
|
EquipmentId = "eq-1",
|
|
Name = "Ok",
|
|
AlarmType = "AlarmCondition",
|
|
Severity = 500,
|
|
MessageTemplate = "ok",
|
|
PredicateScriptId = "s-ok",
|
|
HistorizeToAveva = true,
|
|
Retain = true,
|
|
Enabled = true,
|
|
};
|
|
var orphanAlarm = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmId = "al-orphan",
|
|
EquipmentId = "eq-2",
|
|
Name = "Orphan",
|
|
AlarmType = "AlarmCondition",
|
|
Severity = 500,
|
|
MessageTemplate = "orphan",
|
|
PredicateScriptId = "s-does-not-exist",
|
|
HistorizeToAveva = true,
|
|
Retain = true,
|
|
Enabled = true,
|
|
};
|
|
|
|
var composed = Phase7Composer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
|
Array.Empty<DriverInstance>(), new[] { goodAlarm, orphanAlarm },
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
|
scripts: new[] { goodScript });
|
|
|
|
var blob = BlobOf(new
|
|
{
|
|
ScriptedAlarms = new[] { ToSnapshot(goodAlarm), ToSnapshot(orphanAlarm) },
|
|
Scripts = new[] { new { ScriptId = goodScript.ScriptId, SourceCode = goodScript.SourceCode } },
|
|
});
|
|
|
|
var decoded = DeploymentArtifact.ParseComposition(blob);
|
|
|
|
// The orphan is dropped on both sides; the survivor's plan is byte-identical.
|
|
var composedPlan = composed.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
|
composedPlan.ScriptedAlarmId.ShouldBe("al-ok");
|
|
var decodedPlan = decoded.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
|
decodedPlan.ScriptedAlarmId.ShouldBe("al-ok");
|
|
decodedPlan.ShouldBe(composedPlan);
|
|
}
|
|
|
|
// The full Pascal-case snapshot a ScriptedAlarm EF entity serialises to (matches ConfigComposer).
|
|
private static object ToSnapshot(ScriptedAlarm a) => new
|
|
{
|
|
a.ScriptedAlarmId,
|
|
a.EquipmentId,
|
|
a.Name,
|
|
a.AlarmType,
|
|
a.Severity,
|
|
a.MessageTemplate,
|
|
a.PredicateScriptId,
|
|
a.HistorizeToAveva,
|
|
a.Retain,
|
|
a.Enabled,
|
|
};
|
|
|
|
private static object NewAlarmSnapshot(string id, string equipmentId, string scriptId) => new
|
|
{
|
|
ScriptedAlarmId = id,
|
|
EquipmentId = equipmentId,
|
|
Name = id,
|
|
AlarmType = "AlarmCondition",
|
|
Severity = 500,
|
|
MessageTemplate = $"{id} alarm",
|
|
PredicateScriptId = scriptId,
|
|
HistorizeToAveva = true,
|
|
Retain = true,
|
|
Enabled = true,
|
|
};
|
|
}
|