feat(scripted-alarms): EquipmentScriptedAlarmPlan + Phase7Composer enrichment (T5)
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
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;
|
||||
@@ -61,6 +63,17 @@ public sealed record Phase7CompositionResult(
|
||||
/// <summary>Equipment-namespace VirtualTags. See <see cref="EquipmentVirtualTagPlan"/>. Init-only,
|
||||
/// defaults empty so every existing constructor + call site keeps compiling.</summary>
|
||||
public IReadOnlyList<EquipmentVirtualTagPlan> EquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
|
||||
|
||||
/// <summary>
|
||||
/// Per-equipment scripted-alarm host plans — the richer analogue of the thin
|
||||
/// <see cref="ScriptedAlarmPlans"/> projection. Each carries the fully-resolved predicate
|
||||
/// source (joined from its <see cref="Script"/>) and the merged dependency graph (predicate
|
||||
/// <c>ctx.GetTag("…")</c> reads UNION <c>{TagPath}</c> tokens in the message template) so the
|
||||
/// runtime alarm host can subscribe to every signal and evaluate the predicate. See
|
||||
/// <see cref="EquipmentScriptedAlarmPlan"/>. Init-only, defaults empty so every existing
|
||||
/// constructor + call site keeps compiling unchanged.
|
||||
/// </summary>
|
||||
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
|
||||
}
|
||||
|
||||
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
|
||||
@@ -149,6 +162,78 @@ public sealed record EquipmentVirtualTagPlan(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Equipment-owned scripted alarm from a <see cref="ScriptedAlarm"/> row, joined to its
|
||||
/// predicate <see cref="Script"/> (by <see cref="PredicateScriptId"/>) for the source. The
|
||||
/// richer host analogue of the thin <see cref="ScriptedAlarmPlan"/>: the runtime alarm host
|
||||
/// spawns one Part 9 condition per plan, evaluates <see cref="PredicateSource"/> over
|
||||
/// <see cref="DependencyRefs"/>, and resolves the <see cref="MessageTemplate"/>'s
|
||||
/// <c>{TagPath}</c> tokens at emission time. <see cref="DependencyRefs"/> = the distinct
|
||||
/// <c>ctx.GetTag("…")</c> read literals in the predicate source UNION the distinct
|
||||
/// <c>{TagPath}</c> token paths referenced in the message template (the reserved
|
||||
/// <c>{{equip}}</c> double-brace form is excluded). <see cref="Enabled"/> is carried (never
|
||||
/// dropped) so the host — not the composer — decides whether to host a disabled alarm. Designed
|
||||
/// to be cleanly serializable so the artifact-decode seam (sibling task) can mirror it for
|
||||
/// byte-parity, exactly like <see cref="EquipmentVirtualTagPlan"/>.
|
||||
/// </summary>
|
||||
/// <param name="ScriptedAlarmId">Stable logical id — drives the condition name + diff identity.</param>
|
||||
/// <param name="EquipmentId">Owning equipment folder the alarm hangs under.</param>
|
||||
/// <param name="Name">Operator-facing alarm name.</param>
|
||||
/// <param name="AlarmType">Concrete Part 9 type ("AlarmCondition"/"LimitAlarm"/"OffNormalAlarm"/"DiscreteAlarm").</param>
|
||||
/// <param name="Severity">Numeric severity 1..1000 per OPC UA Part 9.</param>
|
||||
/// <param name="MessageTemplate">Template with <c>{TagPath}</c> tokens resolved at emission time.</param>
|
||||
/// <param name="PredicateScriptId">Logical FK to the predicate script.</param>
|
||||
/// <param name="PredicateSource">The resolved predicate script source (joined by <paramref name="PredicateScriptId"/>).</param>
|
||||
/// <param name="DependencyRefs">Distinct predicate read refs UNION message-template token paths.</param>
|
||||
/// <param name="HistorizeToAveva">When true, transitions route to the Aveva Historian sink.</param>
|
||||
/// <param name="Retain">OPC UA Part 9 <c>Retain</c> flag.</param>
|
||||
/// <param name="Enabled">Whether the alarm is enabled — carried for the host to decide on.</param>
|
||||
public sealed record EquipmentScriptedAlarmPlan(
|
||||
string ScriptedAlarmId,
|
||||
string EquipmentId,
|
||||
string Name,
|
||||
string AlarmType,
|
||||
int Severity,
|
||||
string MessageTemplate,
|
||||
string PredicateScriptId,
|
||||
string PredicateSource,
|
||||
IReadOnlyList<string> DependencyRefs,
|
||||
bool HistorizeToAveva,
|
||||
bool Retain,
|
||||
bool Enabled)
|
||||
{
|
||||
/// <summary>Structural equality: the auto-generated record equality would compare
|
||||
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every alarm as
|
||||
/// "changed" on every parse (fresh list instances). Compare it element-wise so a no-op redeploy
|
||||
/// diffs empty (mirrors <see cref="EquipmentVirtualTagPlan"/>).</summary>
|
||||
public bool Equals(EquipmentScriptedAlarmPlan? other) =>
|
||||
other is not null &&
|
||||
ScriptedAlarmId == other.ScriptedAlarmId &&
|
||||
EquipmentId == other.EquipmentId &&
|
||||
Name == other.Name &&
|
||||
AlarmType == other.AlarmType &&
|
||||
Severity == other.Severity &&
|
||||
MessageTemplate == other.MessageTemplate &&
|
||||
PredicateScriptId == other.PredicateScriptId &&
|
||||
PredicateSource == other.PredicateSource &&
|
||||
HistorizeToAveva == other.HistorizeToAveva &&
|
||||
Retain == other.Retain &&
|
||||
Enabled == other.Enabled &&
|
||||
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(ScriptedAlarmId); hash.Add(EquipmentId); hash.Add(Name);
|
||||
hash.Add(AlarmType); hash.Add(Severity); hash.Add(MessageTemplate);
|
||||
hash.Add(PredicateScriptId); hash.Add(PredicateSource);
|
||||
hash.Add(HistorizeToAveva); hash.Add(Retain); hash.Add(Enabled);
|
||||
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
|
||||
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
|
||||
@@ -325,13 +410,96 @@ public static class Phase7Composer
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Equipment scripted alarms = each ScriptedAlarm joined to its predicate Script (by
|
||||
// PredicateScriptId) for the source. An alarm whose PredicateScriptId has no matching Script
|
||||
// row is SKIPPED (not emitted) with a structured warning rather than failing the whole
|
||||
// compose — the upstream draft validator is the authority that should prevent the dangling
|
||||
// reference. DependencyRefs = the predicate's distinct ctx.GetTag("…") reads UNION the
|
||||
// distinct {TagPath} tokens in the MessageTemplate (predicate reads first, then first-seen
|
||||
// template tokens; the reserved {{equip}} double-brace form is excluded). Enabled is carried
|
||||
// (never dropped) — the runtime host decides whether to host a disabled alarm. Ordered by
|
||||
// EquipmentId then ScriptedAlarmId so the upcoming artifact byte-parity test is reliable.
|
||||
var equipmentScriptedAlarms = scriptedAlarms
|
||||
.OrderBy(a => a.EquipmentId, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.ScriptedAlarmId, StringComparer.Ordinal)
|
||||
.Select(a =>
|
||||
{
|
||||
if (!scriptsById.TryGetValue(a.PredicateScriptId, out var s))
|
||||
{
|
||||
Trace.TraceWarning(
|
||||
"Phase7Composer: scripted alarm '{0}' (equipment '{1}') references predicate " +
|
||||
"script '{2}' which is not in the supplied scripts — skipping.",
|
||||
a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId);
|
||||
return null;
|
||||
}
|
||||
var source = s.SourceCode;
|
||||
return new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: a.ScriptedAlarmId,
|
||||
EquipmentId: a.EquipmentId,
|
||||
Name: a.Name,
|
||||
AlarmType: a.AlarmType,
|
||||
Severity: a.Severity,
|
||||
MessageTemplate: a.MessageTemplate,
|
||||
PredicateScriptId: a.PredicateScriptId,
|
||||
PredicateSource: source,
|
||||
DependencyRefs: MergeAlarmDependencyRefs(source, a.MessageTemplate),
|
||||
HistorizeToAveva: a.HistorizeToAveva,
|
||||
Retain: a.Retain,
|
||||
Enabled: a.Enabled);
|
||||
})
|
||||
.Where(p => p is not null)
|
||||
.Select(p => p!)
|
||||
.ToList();
|
||||
|
||||
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags)
|
||||
{
|
||||
EquipmentTags = equipmentTags,
|
||||
EquipmentVirtualTags = equipmentVirtualTags,
|
||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||
};
|
||||
}
|
||||
|
||||
// {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
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the live-edit compose seam (<see cref="Phase7Composer.Compose"/>) builds the richer
|
||||
/// per-equipment scripted-alarm projection (<see cref="EquipmentScriptedAlarmPlan"/>): each alarm
|
||||
/// is joined to its predicate <see cref="Script"/> for the source, carries the parsed
|
||||
/// <c>ctx.GetTag("…")</c> read refs UNION the <c>{TagPath}</c> tokens referenced in its
|
||||
/// <c>MessageTemplate</c> as DependencyRefs, and preserves every flag the host decides on
|
||||
/// (including disabled alarms). The projection is deterministic so the upcoming
|
||||
/// artifact-byte-parity test (sibling task) is reliable.
|
||||
/// </summary>
|
||||
public sealed class Phase7ComposerScriptedAlarmTests
|
||||
{
|
||||
/// <summary>Two equipments, each with a scripted alarm whose predicate script reads one tag via
|
||||
/// <c>ctx.GetTag("X.Y")</c>. Each plan must carry the resolved PredicateSource, DependencyRefs
|
||||
/// containing the read path, and every scalar field copied through faithfully.</summary>
|
||||
[Fact]
|
||||
public void Compose_emits_richer_scripted_alarm_plan_per_equipment_joined_to_script()
|
||||
{
|
||||
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 overheating",
|
||||
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 overheating",
|
||||
PredicateScriptId = "s-2",
|
||||
HistorizeToAveva = false,
|
||||
Retain = false,
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
var result = 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 });
|
||||
|
||||
result.EquipmentScriptedAlarms.Count.ShouldBe(2);
|
||||
|
||||
var plan1 = result.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1");
|
||||
plan1.EquipmentId.ShouldBe("eq-1");
|
||||
plan1.Name.ShouldBe("Overheat-1");
|
||||
plan1.AlarmType.ShouldBe("LimitAlarm");
|
||||
plan1.Severity.ShouldBe(700);
|
||||
plan1.MessageTemplate.ShouldBe("Machine 1 overheating");
|
||||
plan1.PredicateScriptId.ShouldBe("s-1");
|
||||
plan1.PredicateSource.ShouldBe(script1.SourceCode);
|
||||
plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp" });
|
||||
plan1.HistorizeToAveva.ShouldBeTrue();
|
||||
plan1.Retain.ShouldBeTrue();
|
||||
plan1.Enabled.ShouldBeTrue();
|
||||
|
||||
var plan2 = result.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2");
|
||||
plan2.EquipmentId.ShouldBe("eq-2");
|
||||
plan2.AlarmType.ShouldBe("OffNormalAlarm");
|
||||
plan2.Severity.ShouldBe(250);
|
||||
plan2.PredicateSource.ShouldBe(script2.SourceCode);
|
||||
plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp" });
|
||||
plan2.HistorizeToAveva.ShouldBeFalse();
|
||||
plan2.Retain.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>The <c>{TagPath}</c> tokens in the MessageTemplate are merged into DependencyRefs
|
||||
/// alongside the predicate's <c>ctx.GetTag</c> reads, so the host can subscribe to every signal
|
||||
/// the alarm references for either the predicate or the message.</summary>
|
||||
[Fact]
|
||||
public void Compose_merges_message_template_token_paths_into_dependency_refs()
|
||||
{
|
||||
var script = new Script
|
||||
{
|
||||
ScriptId = "s-1",
|
||||
Name = "hot",
|
||||
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Line.Pressure\").Value) > 5;",
|
||||
SourceHash = "h1",
|
||||
};
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-1",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "TempHigh",
|
||||
AlarmType = "LimitAlarm",
|
||||
Severity = 500,
|
||||
MessageTemplate = "Temp {Line.Temp} high",
|
||||
PredicateScriptId = "s-1",
|
||||
};
|
||||
|
||||
var result = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { alarm },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
||||
scripts: new[] { script });
|
||||
|
||||
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
// Predicate read + message token are both present (predicate reads first, then template tokens).
|
||||
plan.DependencyRefs.ShouldBe(new[] { "Line.Pressure", "Line.Temp" });
|
||||
}
|
||||
|
||||
/// <summary>A template token that duplicates a predicate read collapses to one ref, and a
|
||||
/// repeated template token collapses too — DependencyRefs are distinct (predicate reads first,
|
||||
/// then first-seen template tokens), and the reserved <c>{{equip}}</c> double-brace form is not
|
||||
/// harvested as a token.</summary>
|
||||
[Fact]
|
||||
public void Compose_dependency_refs_are_distinct_and_exclude_the_reserved_equip_token()
|
||||
{
|
||||
var script = new Script
|
||||
{
|
||||
ScriptId = "s-1",
|
||||
Name = "dup",
|
||||
SourceCode = "return ctx.GetTag(\"A.X\").Value;",
|
||||
SourceHash = "h1",
|
||||
};
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-1",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Dup",
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
// {A.X} duplicates the predicate read; {B.Y} appears twice; {{equip}}.Z must NOT be a token.
|
||||
MessageTemplate = "v={A.X} w={B.Y} x={B.Y} base={{equip}}.Z",
|
||||
PredicateScriptId = "s-1",
|
||||
};
|
||||
|
||||
var result = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { alarm },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
||||
scripts: new[] { script });
|
||||
|
||||
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
plan.DependencyRefs.ShouldBe(new[] { "A.X", "B.Y" });
|
||||
plan.DependencyRefs.ShouldNotContain("equip");
|
||||
}
|
||||
|
||||
/// <summary>A scripted alarm whose PredicateScriptId matches no Script row is SKIPPED (not in the
|
||||
/// result) and compose does NOT throw — the bad row is dropped, the rest of the projection stands.</summary>
|
||||
[Fact]
|
||||
public void Compose_skips_scripted_alarm_with_missing_predicate_script_without_throwing()
|
||||
{
|
||||
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",
|
||||
};
|
||||
var orphanAlarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-orphan",
|
||||
EquipmentId = "eq-2",
|
||||
Name = "Orphan",
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = "orphan",
|
||||
PredicateScriptId = "s-does-not-exist",
|
||||
};
|
||||
|
||||
var result = 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 plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
plan.ScriptedAlarmId.ShouldBe("al-ok");
|
||||
result.EquipmentScriptedAlarms.ShouldNotContain(p => p.ScriptedAlarmId == "al-orphan");
|
||||
}
|
||||
|
||||
/// <summary>A disabled alarm (<c>Enabled=false</c>) is STILL present in the projection — the host
|
||||
/// decides whether to host it, the composer never drops it. The flag is carried through.</summary>
|
||||
[Fact]
|
||||
public void Compose_keeps_disabled_scripted_alarm_with_enabled_flag_carried()
|
||||
{
|
||||
var script = new Script
|
||||
{
|
||||
ScriptId = "s-1",
|
||||
Name = "off",
|
||||
SourceCode = "return ctx.GetTag(\"A.X\").Value;",
|
||||
SourceHash = "h1",
|
||||
};
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-off",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Disabled",
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = "off",
|
||||
PredicateScriptId = "s-1",
|
||||
Enabled = false,
|
||||
};
|
||||
|
||||
var result = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { alarm },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
||||
scripts: new[] { script });
|
||||
|
||||
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
plan.ScriptedAlarmId.ShouldBe("al-off");
|
||||
plan.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Determinism: the same inputs in different order produce identical ordered output
|
||||
/// (stable sort by EquipmentId then ScriptedAlarmId) — this is the contract the upcoming
|
||||
/// artifact byte-parity test relies on.</summary>
|
||||
[Fact]
|
||||
public void Compose_scripted_alarm_projection_is_deterministic_regardless_of_input_order()
|
||||
{
|
||||
var script1 = new Script { ScriptId = "s-1", Name = "a", SourceCode = "return ctx.GetTag(\"A.X\").Value;", SourceHash = "h1" };
|
||||
var script2 = new Script { ScriptId = "s-2", Name = "b", SourceCode = "return ctx.GetTag(\"B.Y\").Value;", SourceHash = "h2" };
|
||||
var script3 = new Script { ScriptId = "s-3", Name = "c", SourceCode = "return ctx.GetTag(\"C.Z\").Value;", SourceHash = "h3" };
|
||||
|
||||
// Two alarms on the same equipment to exercise the secondary (ScriptedAlarmId) sort key.
|
||||
var a1 = NewAlarm("al-1", "eq-2", "s-1");
|
||||
var a2 = NewAlarm("al-2", "eq-1", "s-2");
|
||||
var a3 = NewAlarm("al-3", "eq-1", "s-3");
|
||||
var scripts = new[] { script1, script2, script3 };
|
||||
|
||||
var r1 = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { a1, a2, a3 },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(), scripts: scripts);
|
||||
|
||||
var r2 = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { a3, a1, a2 },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(), scripts: scripts);
|
||||
|
||||
// Sorted by EquipmentId then ScriptedAlarmId: (eq-1,al-2), (eq-1,al-3), (eq-2,al-1).
|
||||
r1.EquipmentScriptedAlarms.Select(p => p.ScriptedAlarmId)
|
||||
.ShouldBe(new[] { "al-2", "al-3", "al-1" });
|
||||
r1.EquipmentScriptedAlarms.ShouldBe(r2.EquipmentScriptedAlarms);
|
||||
}
|
||||
|
||||
/// <summary>The new projection defaults to empty when no scripts/alarms are supplied (init-only
|
||||
/// member, no positional-parameter break for existing callers).</summary>
|
||||
[Fact]
|
||||
public void Composition_carries_empty_scripted_alarms_by_default()
|
||||
{
|
||||
var r = new Phase7CompositionResult(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
r.EquipmentScriptedAlarms.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static ScriptedAlarm NewAlarm(string id, string equipmentId, string scriptId) => new()
|
||||
{
|
||||
ScriptedAlarmId = id,
|
||||
EquipmentId = equipmentId,
|
||||
Name = id,
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = $"{id} alarm",
|
||||
PredicateScriptId = scriptId,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user