Scope alarm tracking to selected templates and surface endpoint/security state on the dashboard so operators can deploy in large galaxies without drowning clients in irrelevant alarms or guessing what the server is advertising

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-13 09:48:57 -04:00
parent c5ed5312a9
commit 517d92c76f
25 changed files with 1511 additions and 12 deletions

View File

@@ -0,0 +1,206 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
{
/// <summary>
/// End-to-end integration tests that boot a real LmxNodeManager against fake Galaxy data and verify
/// the template-based alarm object filter actually suppresses alarm condition creation in both the
/// full build path and the subtree rebuild path after a simulated Galaxy redeploy.
/// </summary>
public class AlarmObjectFilterIntegrationTests
{
[Fact]
public async Task Filter_Empty_AllAlarmsTracked()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
repo: CreateRepoWithMixedTemplates(),
alarmTrackingEnabled: true);
await fixture.InitializeAsync();
try
{
fixture.NodeManager.ShouldNotBeNull();
// Two alarm attributes total (one per object), no filter → both tracked.
fixture.NodeManager!.AlarmConditionCount.ShouldBe(2);
fixture.NodeManager.AlarmFilterEnabled.ShouldBeFalse();
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(0);
}
finally
{
await fixture.DisposeAsync();
}
}
[Fact]
public async Task Filter_MatchesOneTemplate_OnlyMatchingAlarmTracked()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
repo: CreateRepoWithMixedTemplates(),
alarmTrackingEnabled: true,
alarmObjectFilters: new[] { "TestMachine*" });
await fixture.InitializeAsync();
try
{
fixture.NodeManager.ShouldNotBeNull();
fixture.NodeManager!.AlarmFilterEnabled.ShouldBeTrue();
fixture.NodeManager.AlarmFilterPatternCount.ShouldBe(1);
fixture.NodeManager.AlarmConditionCount.ShouldBe(1);
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(1);
}
finally
{
await fixture.DisposeAsync();
}
}
[Fact]
public async Task Filter_MatchesParent_PropagatesToChild()
{
var attrs = new List<GalaxyAttributeInfo>();
attrs.AddRange(AlarmWithInAlarm(1, "Parent_001", "AlarmA"));
attrs.AddRange(AlarmWithInAlarm(2, "Child_001", "AlarmB"));
var repo = new FakeGalaxyRepository
{
Hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, tag: "Parent_001", template: "TestMachine"),
Obj(2, parent: 1, tag: "Child_001", template: "UnrelatedWidget")
},
Attributes = attrs
};
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
repo: repo,
alarmTrackingEnabled: true,
alarmObjectFilters: new[] { "TestMachine*" });
await fixture.InitializeAsync();
try
{
fixture.NodeManager!.AlarmConditionCount.ShouldBe(2);
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(2);
}
finally
{
await fixture.DisposeAsync();
}
}
[Fact]
public async Task Filter_NoMatch_ZeroAlarmConditions()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
repo: CreateRepoWithMixedTemplates(),
alarmTrackingEnabled: true,
alarmObjectFilters: new[] { "NotInGalaxy*" });
await fixture.InitializeAsync();
try
{
fixture.NodeManager!.AlarmConditionCount.ShouldBe(0);
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(0);
fixture.NodeManager.AlarmFilterPatternCount.ShouldBe(1);
}
finally
{
await fixture.DisposeAsync();
}
}
[Fact]
public async Task Filter_GalaxyDollarPrefix_Normalized()
{
// Template chain stored as "$TestMachine" must match operator pattern "TestMachine*".
var repo = new FakeGalaxyRepository
{
Hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, tag: "Obj_1", template: "$TestMachine")
},
Attributes = new List<GalaxyAttributeInfo>(AlarmWithInAlarm(1, "Obj_1", "AlarmX"))
};
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
repo: repo,
alarmTrackingEnabled: true,
alarmObjectFilters: new[] { "TestMachine*" });
await fixture.InitializeAsync();
try
{
fixture.NodeManager!.AlarmConditionCount.ShouldBe(1);
}
finally
{
await fixture.DisposeAsync();
}
}
// ---------- Helpers ----------
private static FakeGalaxyRepository CreateRepoWithMixedTemplates()
{
var attrs = new List<GalaxyAttributeInfo>();
attrs.AddRange(AlarmWithInAlarm(1, "TestMachine_001", "MachineAlarm"));
attrs.AddRange(AlarmWithInAlarm(2, "Pump_001", "PumpAlarm"));
return new FakeGalaxyRepository
{
Hierarchy = new List<GalaxyObjectInfo>
{
Obj(1, parent: 0, tag: "TestMachine_001", template: "TestMachine"),
Obj(2, parent: 0, tag: "Pump_001", template: "Pump")
},
Attributes = attrs
};
}
/// <summary>
/// Builds a Galaxy alarm attribute plus its companion <c>.InAlarm</c> sub-attribute. The alarm
/// creation path in LmxNodeManager skips objects whose alarm attribute has no InAlarm variable
/// in the tag→node map, so tests must include both rows for alarm conditions to materialize.
/// </summary>
private static IEnumerable<GalaxyAttributeInfo> AlarmWithInAlarm(int gobjectId, string tag, string attrName)
{
yield return new GalaxyAttributeInfo
{
GobjectId = gobjectId,
TagName = tag,
AttributeName = attrName,
FullTagReference = $"{tag}.{attrName}",
MxDataType = 1,
IsAlarm = true
};
yield return new GalaxyAttributeInfo
{
GobjectId = gobjectId,
TagName = tag,
AttributeName = attrName + ".InAlarm",
FullTagReference = $"{tag}.{attrName}.InAlarm",
MxDataType = 1,
IsAlarm = false
};
}
private static GalaxyObjectInfo Obj(int id, int parent, string tag, string template) => new()
{
GobjectId = id,
ParentGobjectId = parent,
TagName = tag,
ContainedName = tag,
BrowseName = tag,
IsArea = false,
TemplateChain = new List<string> { template }
};
private static GalaxyAttributeInfo AlarmAttr(int gobjectId, string tag, string attrName) => new()
{
GobjectId = gobjectId,
TagName = tag,
AttributeName = attrName,
FullTagReference = $"{tag}.{attrName}",
MxDataType = 1,
IsAlarm = true
};
}
}