feat(scripted-alarms): DeploymentArtifact byte-parity for the alarm plan (T6)
This commit is contained in:
@@ -20,6 +20,12 @@ public static class EquipmentScriptPaths
|
||||
private static readonly Regex GetTagRefRegex =
|
||||
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled);
|
||||
|
||||
// {TagPath} message-template token — a single-brace {…} run. The negative look-behind/-ahead
|
||||
// reject the reserved {{equip}} double-brace form (and any other doubled brace), so "{A.X}"
|
||||
// matches but "{{equip}}" does not yield "{equip}".
|
||||
private static readonly Regex MessageTemplateTokenRegex =
|
||||
new(@"(?<!\{)\{([^{}]+)\}(?!\})", RegexOptions.Compiled);
|
||||
|
||||
// ctx.GetTag("…") OR ctx.SetVirtualTag("…", …) — first string-literal arg captured in
|
||||
// three parts (prefix, content, closing quote) so token substitution touches ONLY the
|
||||
// literal content (never a comment, Logger string, or other code).
|
||||
@@ -92,4 +98,42 @@ public static class EquipmentScriptPaths
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge a scripted alarm's dependency graph: the predicate script's distinct
|
||||
/// <c>ctx.GetTag("…")</c> read refs (via <see cref="ExtractDependencyRefs"/>, first-seen order)
|
||||
/// UNION the distinct <c>{TagPath}</c> token paths referenced in the message template (first-seen
|
||||
/// order, appended after the predicate reads, trimmed + non-empty). The reserved
|
||||
/// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the live
|
||||
/// composer (<c>Phase7Composer</c>) and the artifact-decode mirror (<c>DeploymentArtifact</c>)
|
||||
/// produce the exact same ordered list — the byte-parity contract <c>EquipmentScriptedAlarmPlan</c>
|
||||
/// equality depends on. Scripted alarms do NOT use <c>{{equip}}</c> substitution (only virtual
|
||||
/// tags do) — pass the predicate source as-is.
|
||||
/// </summary>
|
||||
/// <param name="predicateSource">The resolved predicate script source.</param>
|
||||
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
|
||||
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
|
||||
public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<string>();
|
||||
|
||||
// Predicate reads first — same regex extraction the VirtualTag projection uses.
|
||||
foreach (var r in ExtractDependencyRefs(predicateSource))
|
||||
{
|
||||
if (seen.Add(r)) result.Add(r);
|
||||
}
|
||||
|
||||
// Then message-template {TagPath} tokens (first-seen), trimmed, non-empty.
|
||||
if (!string.IsNullOrEmpty(messageTemplate))
|
||||
{
|
||||
foreach (Match m in MessageTemplateTokenRegex.Matches(messageTemplate))
|
||||
{
|
||||
var token = m.Groups[1].Value.Trim();
|
||||
if (token.Length > 0 && seen.Add(token)) result.Add(token);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -208,10 +207,10 @@ public sealed record EquipmentScriptedAlarmPlan(
|
||||
/// diffs empty (mirrors <see cref="EquipmentVirtualTagPlan"/>).</summary>
|
||||
/// <remarks>
|
||||
/// <b>DependencyRefs equality is order-sensitive</b> (SequenceEqual).
|
||||
/// <see cref="Phase7Composer.MergeAlarmDependencyRefs"/> is the canonical, deterministic
|
||||
/// <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> is the canonical, deterministic
|
||||
/// producer of that order (predicate <c>ctx.GetTag</c> reads first, then first-seen message
|
||||
/// template tokens). Downstream byte-parity between the live composer and the artifact-decode
|
||||
/// mirror depends on both sides calling <c>MergeAlarmDependencyRefs</c> with identical inputs.
|
||||
/// mirror depends on both sides calling <c>ExtractAlarmDependencyRefs</c> with identical inputs.
|
||||
/// </remarks>
|
||||
public bool Equals(EquipmentScriptedAlarmPlan? other) =>
|
||||
other is not null &&
|
||||
@@ -452,7 +451,10 @@ public static class Phase7Composer
|
||||
MessageTemplate: a.MessageTemplate,
|
||||
PredicateScriptId: a.PredicateScriptId,
|
||||
PredicateSource: source,
|
||||
DependencyRefs: MergeAlarmDependencyRefs(source, a.MessageTemplate),
|
||||
// Scripted alarms do NOT use {{equip}} substitution (only virtual tags do) — pass the
|
||||
// predicate source as-is. The merge (predicate reads first, then template tokens) lives
|
||||
// in the shared EquipmentScriptPaths helper so the artifact-decode mirror agrees.
|
||||
DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate),
|
||||
HistorizeToAveva: a.HistorizeToAveva,
|
||||
Retain: a.Retain,
|
||||
Enabled: a.Enabled));
|
||||
@@ -466,47 +468,6 @@ public static class Phase7Composer
|
||||
};
|
||||
}
|
||||
|
||||
// {TagPath} message-template token — a single-brace {…} run. Reuses no existing helper because
|
||||
// EquipmentScriptPaths only handles {{equip}} + ctx.GetTag literals; this is the alarm-message
|
||||
// surface. The negative look-behind/-ahead reject the reserved {{equip}} double-brace form
|
||||
// (and any other doubled brace), so "{A.X}" matches but "{{equip}}" does not yield "{equip}".
|
||||
private static readonly Regex MessageTemplateTokenRegex =
|
||||
new(@"(?<!\{)\{([^{}]+)\}(?!\})", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Merge the alarm's dependency graph: the predicate script's distinct <c>ctx.GetTag("…")</c>
|
||||
/// read refs (first-seen order) UNION the distinct <c>{TagPath}</c> token paths referenced in
|
||||
/// the message template (first-seen order, appended after the predicate reads). The reserved
|
||||
/// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the
|
||||
/// artifact-decode mirror (sibling task) can reproduce the exact same ordered list.
|
||||
/// </summary>
|
||||
/// <param name="predicateSource">The resolved predicate script source.</param>
|
||||
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
|
||||
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
|
||||
private static IReadOnlyList<string> MergeAlarmDependencyRefs(string predicateSource, string? messageTemplate)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<string>();
|
||||
|
||||
// Predicate reads first — same regex extraction the VirtualTag projection uses.
|
||||
foreach (var r in EquipmentScriptPaths.ExtractDependencyRefs(predicateSource))
|
||||
{
|
||||
if (seen.Add(r)) result.Add(r);
|
||||
}
|
||||
|
||||
// Then message-template {TagPath} tokens (first-seen), trimmed, non-empty.
|
||||
if (!string.IsNullOrEmpty(messageTemplate))
|
||||
{
|
||||
foreach (Match m in MessageTemplateTokenRegex.Matches(messageTemplate))
|
||||
{
|
||||
var token = m.Groups[1].Value.Trim();
|
||||
if (token.Length > 0 && seen.Add(token)) result.Add(token);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract the driver-side full reference from a <see cref="Tag.TagConfig"/> JSON blob: the
|
||||
/// <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a JSON object, and every shipped
|
||||
|
||||
@@ -195,11 +195,13 @@ public static class DeploymentArtifact
|
||||
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
|
||||
var equipmentTags = BuildEquipmentTagPlans(root);
|
||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
|
||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
|
||||
|
||||
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags)
|
||||
{
|
||||
EquipmentTags = equipmentTags,
|
||||
EquipmentVirtualTags = equipmentVirtualTags,
|
||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
@@ -261,6 +263,7 @@ public static class DeploymentArtifact
|
||||
{
|
||||
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
||||
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
|
||||
EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -606,6 +609,87 @@ public static class DeploymentArtifact
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Join the artifact's ScriptedAlarms array to its Scripts array (by PredicateScriptId) to emit
|
||||
/// one <see cref="EquipmentScriptedAlarmPlan"/> per alarm. The artifact-decode mirror of
|
||||
/// <c>Phase7Composer.Compose</c>'s scripted-alarm producer — so the compose-side + artifact-decode
|
||||
/// plans agree byte-for-byte. An alarm whose <c>PredicateScriptId</c> has no matching Script row is
|
||||
/// SKIPPED (matching the composer's skip behaviour) to preserve parity. <c>PredicateSource</c> = the
|
||||
/// joined script source ("" when missing — but such alarms are skipped above); <c>DependencyRefs</c>
|
||||
/// = the shared <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> merge of the predicate's
|
||||
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. Scripted
|
||||
/// alarms do NOT use <c>{{equip}}</c> substitution (only virtual tags do) — the predicate source is
|
||||
/// used as-is. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentScriptedAlarmPlan>();
|
||||
|
||||
// scriptId → SourceCode (the predicate source the alarm host evaluates). Same join the
|
||||
// VirtualTag builder uses; an alarm whose PredicateScriptId is absent here is skipped below.
|
||||
var scriptSourceById = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in scriptsArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var sid = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(sid)) continue;
|
||||
var src = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
||||
? srcEl.GetString() : null;
|
||||
scriptSourceById[sid!] = src ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<EquipmentScriptedAlarmPlan>(alarmsArr.GetArrayLength());
|
||||
foreach (var el in alarmsArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var scriptedAlarmId = el.TryGetProperty("ScriptedAlarmId", out var idEl) ? idEl.GetString() : null;
|
||||
var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null;
|
||||
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
|
||||
var alarmType = el.TryGetProperty("AlarmType", out var atEl) ? atEl.GetString() : null;
|
||||
var severity = el.TryGetProperty("Severity", out var svEl) && svEl.TryGetInt32(out var sv) ? sv : 0;
|
||||
var messageTemplate = el.TryGetProperty("MessageTemplate", out var mtEl) ? mtEl.GetString() : null;
|
||||
var predicateScriptId = el.TryGetProperty("PredicateScriptId", out var psEl) ? psEl.GetString() : null;
|
||||
var historize = el.TryGetProperty("HistorizeToAveva", out var hEl) && hEl.ValueKind != JsonValueKind.Null
|
||||
? hEl.GetBoolean() : false;
|
||||
var retain = el.TryGetProperty("Retain", out var rEl) && rEl.ValueKind != JsonValueKind.Null
|
||||
? rEl.GetBoolean() : false;
|
||||
var enabled = el.TryGetProperty("Enabled", out var enEl) && enEl.ValueKind != JsonValueKind.Null
|
||||
? enEl.GetBoolean() : false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scriptedAlarmId)) continue;
|
||||
|
||||
// Skip alarms whose predicate script is missing — matching Phase7Composer's skip behaviour
|
||||
// so both sides emit the same set (byte-parity).
|
||||
if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var source))
|
||||
continue;
|
||||
|
||||
result.Add(new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: scriptedAlarmId!,
|
||||
EquipmentId: equipmentId ?? string.Empty,
|
||||
Name: name ?? string.Empty,
|
||||
AlarmType: alarmType ?? string.Empty,
|
||||
Severity: severity,
|
||||
MessageTemplate: messageTemplate ?? string.Empty,
|
||||
PredicateScriptId: predicateScriptId,
|
||||
PredicateSource: source,
|
||||
DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, messageTemplate),
|
||||
HistorizeToAveva: historize,
|
||||
Retain: retain,
|
||||
Enabled: enabled));
|
||||
}
|
||||
|
||||
result.Sort((a, b) =>
|
||||
{
|
||||
var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId);
|
||||
return byEquipment != 0 ? byEquipment : string.CompareOrdinal(a.ScriptedAlarmId, b.ScriptedAlarmId);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
|
||||
/// field). The artifact-decode mirror of <c>Phase7Composer.ExtractTagFullName</c> /
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user