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:
416
tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/AlarmObjectFilterTests.cs
Normal file
416
tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/AlarmObjectFilterTests.cs
Normal file
@@ -0,0 +1,416 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Exhaustive coverage of the template-based alarm object filter's pattern parsing,
|
||||
/// chain matching, and hierarchy-subtree propagation logic.
|
||||
/// </summary>
|
||||
public class AlarmObjectFilterTests
|
||||
{
|
||||
// ---------- Pattern parsing & compilation ----------
|
||||
|
||||
[Fact]
|
||||
public void EmptyConfig_DisablesFilter()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(new AlarmFilterConfiguration());
|
||||
sut.Enabled.ShouldBeFalse();
|
||||
sut.PatternCount.ShouldBe(0);
|
||||
sut.ResolveIncludedObjects(SingleObject()).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullConfig_DisablesFilter()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(null);
|
||||
sut.Enabled.ShouldBeFalse();
|
||||
sut.ResolveIncludedObjects(SingleObject()).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhitespaceEntries_AreSkipped()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("", " ", "\t"));
|
||||
sut.Enabled.ShouldBeFalse();
|
||||
sut.PatternCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommaSeparatedEntry_SplitsIntoMultiplePatterns()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*, Pump_*"));
|
||||
sut.Enabled.ShouldBeTrue();
|
||||
sut.PatternCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommaAndListForms_Combine()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("A*, B*", "C*"));
|
||||
sut.PatternCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhitespaceAroundCommas_IsTrimmed()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config(" TestMachine* , Pump_* "));
|
||||
sut.PatternCount.ShouldBe(2);
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "Pump_A" }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiteralPattern_MatchesExactTemplate()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine_001" }).ShouldBeFalse();
|
||||
sut.MatchesTemplateChain(new List<string> { "OtherMachine" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StarAlonePattern_MatchesAnyNonEmptyChain()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "Foo" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "Bar", "Baz" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string>()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrefixWildcard_MatchesSuffix()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("*Machine"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "BigMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "MachineThing" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuffixWildcard_MatchesPrefix()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("Test*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "TestFoo" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "Machine" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothWildcards_MatchesContains()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("*Machine*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachineWidget" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "Machine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "Pump" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MiddleWildcard_MatchesWithInnerAnything()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("Test*Machine"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "TestCoolMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachineX" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegexMetacharacters_AreEscapedLiterally()
|
||||
{
|
||||
// The '.' in Pump.v2 is a regex metachar; it must be a literal dot.
|
||||
var sut = new AlarmObjectFilter(Config("Pump.v2"));
|
||||
sut.MatchesTemplateChain(new List<string> { "Pump.v2" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "PumpXv2" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matching_IsCaseInsensitive()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("testmachine*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine_001" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "TESTMACHINE_XYZ" }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GalaxyDollarPrefix_IsNormalizedAway_OnBothSides()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "$TestMachine" }).ShouldBeTrue();
|
||||
|
||||
var withDollarInPattern = new AlarmObjectFilter(Config("$TestMachine*"));
|
||||
withDollarInPattern.MatchesTemplateChain(new List<string> { "$TestMachine" }).ShouldBeTrue();
|
||||
withDollarInPattern.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------- Template-chain matching ----------
|
||||
|
||||
[Fact]
|
||||
public void ChainMatch_AtAncestorPosition_StillMatches()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var chain = new List<string> { "TestCoolMachine", "TestMachine", "$UserDefined" };
|
||||
sut.MatchesTemplateChain(chain).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChainNoMatch_ReturnsFalse()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
var chain = new List<string> { "FooBar", "$UserDefined" };
|
||||
sut.MatchesTemplateChain(chain).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyChain_NeverMatchesNonWildcard()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
sut.MatchesTemplateChain(new List<string>()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullChain_NeverMatches()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
sut.MatchesTemplateChain(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemTemplate_MatchesWhenOperatorOptsIn()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("Area*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "$Area" }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateChainEntries_StillMatch()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var chain = new List<string> { "TestMachine", "TestMachine", "$UserDefined" };
|
||||
sut.MatchesTemplateChain(chain).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------- Hierarchy subtree propagation ----------
|
||||
|
||||
[Fact]
|
||||
public void FlatHierarchy_OnlyMatchingIdsIncluded()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"),
|
||||
Obj(2, parent: 0, template: "Pump"),
|
||||
Obj(3, parent: 0, template: "TestMachine")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldContain(1);
|
||||
included.ShouldContain(3);
|
||||
included.ShouldNotContain(2);
|
||||
included.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchOnGrandparent_PropagatesToGrandchildren()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"), // root matches
|
||||
Obj(2, parent: 1, template: "UnrelatedThing"), // child — inherited
|
||||
Obj(3, parent: 2, template: "UnrelatedOtherThing") // grandchild — inherited
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldBe(new[] { 1, 2, 3 }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GrandchildMatch_DoesNotIncludeAncestors()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "Unrelated"),
|
||||
Obj(2, parent: 1, template: "Unrelated"),
|
||||
Obj(3, parent: 2, template: "TestMachine")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldBe(new[] { 3 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlappingMatches_StillSingleInclude()
|
||||
{
|
||||
// Grandparent matches AND grandchild matches independently — grandchild still counted once.
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"),
|
||||
Obj(2, parent: 1, template: "Widget"),
|
||||
Obj(3, parent: 2, template: "TestMachine")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.Count.ShouldBe(3);
|
||||
included.ShouldContain(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiblingSubtrees_OnlyMatchedSideIncluded()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"), // match — left subtree
|
||||
Obj(2, parent: 1, template: "Child"),
|
||||
Obj(10, parent: 0, template: "Pump"), // no match — right subtree
|
||||
Obj(11, parent: 10, template: "PumpChild")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldBe(new[] { 1, 2 }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
// ---------- Defensive / edge cases ----------
|
||||
|
||||
[Fact]
|
||||
public void OrphanObject_TreatedAsRoot()
|
||||
{
|
||||
// Object 2 claims parent 99 which isn't in the hierarchy — still reached as a root.
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(2, parent: 99, template: "TestMachine")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldContain(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyntheticCycle_TerminatesWithoutStackOverflow()
|
||||
{
|
||||
// A→B→A cycle defended by the visited set.
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 2, template: "TestMachine"),
|
||||
Obj(2, parent: 1, template: "Widget")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
// No object has a ParentGobjectId of 0, and each references an id that exists —
|
||||
// neither qualifies as a root under the orphan rule. Empty result is acceptable;
|
||||
// the critical assertion is that the call returns without crashing.
|
||||
var included = sut.ResolveIncludedObjects(hierarchy);
|
||||
included.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullTemplateChain_TreatedAsEmpty()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, ParentGobjectId = 0, TemplateChain = null! }
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyHierarchy_ReturnsEmptySet()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(new List<GalaxyObjectInfo>())!;
|
||||
included.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullHierarchy_ReturnsEmptySet()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(null)!;
|
||||
included.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleRoots_AllProcessed()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"),
|
||||
Obj(2, parent: 0, template: "TestMachine"),
|
||||
Obj(3, parent: 0, template: "Pump")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---------- UnmatchedPatterns ----------
|
||||
|
||||
[Fact]
|
||||
public void UnmatchedPatterns_ListsPatternsWithZeroHits()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*", "NotThere*"));
|
||||
sut.ResolveIncludedObjects(hierarchy);
|
||||
sut.UnmatchedPatterns.ShouldContain("NotThere*");
|
||||
sut.UnmatchedPatterns.ShouldNotContain("TestMachine*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnmatchedPatterns_EmptyWhenAllMatch()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"),
|
||||
Obj(2, parent: 0, template: "Pump")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine", "Pump"));
|
||||
sut.ResolveIncludedObjects(hierarchy);
|
||||
sut.UnmatchedPatterns.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnmatchedPatterns_EmptyWhenFilterDisabled()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(new AlarmFilterConfiguration());
|
||||
sut.UnmatchedPatterns.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnmatchedPatterns_ResetBetweenResolutions()
|
||||
{
|
||||
var hierarchyA = new List<GalaxyObjectInfo> { Obj(1, parent: 0, template: "TestMachine") };
|
||||
var hierarchyB = new List<GalaxyObjectInfo> { Obj(1, parent: 0, template: "Pump") };
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
|
||||
sut.ResolveIncludedObjects(hierarchyA);
|
||||
sut.UnmatchedPatterns.ShouldBeEmpty();
|
||||
|
||||
sut.ResolveIncludedObjects(hierarchyB);
|
||||
sut.UnmatchedPatterns.ShouldContain("TestMachine*");
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private static AlarmFilterConfiguration Config(params string[] filters) =>
|
||||
new() { ObjectFilters = filters.ToList() };
|
||||
|
||||
private static GalaxyObjectInfo Obj(int id, int parent, string template) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
ParentGobjectId = parent,
|
||||
TagName = $"Obj_{id}",
|
||||
BrowseName = $"Obj_{id}",
|
||||
TemplateChain = new List<string> { template }
|
||||
};
|
||||
|
||||
private static List<GalaxyObjectInfo> SingleObject() => new()
|
||||
{
|
||||
Obj(1, parent: 0, template: "Anything")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
string? applicationUri = null,
|
||||
string? serverName = null,
|
||||
AuthenticationConfiguration? authConfig = null,
|
||||
IUserAuthenticationProvider? authProvider = null)
|
||||
IUserAuthenticationProvider? authProvider = null,
|
||||
bool alarmTrackingEnabled = false,
|
||||
string[]? alarmObjectFilters = null)
|
||||
{
|
||||
var client = mxClient ?? new FakeMxAccessClient();
|
||||
var r = repo ?? new FakeGalaxyRepository
|
||||
@@ -176,8 +178,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
builder.WithAuthentication(authConfig);
|
||||
if (authProvider != null)
|
||||
builder.WithAuthProvider(authProvider);
|
||||
if (alarmTrackingEnabled)
|
||||
builder.WithAlarmTracking(true);
|
||||
if (alarmObjectFilters != null)
|
||||
builder.WithAlarmFilter(alarmObjectFilters);
|
||||
|
||||
return new OpcUaServerFixture(builder, r, client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the node manager currently published by the running fixture so tests can assert
|
||||
/// filter counters, alarm condition counts, and other runtime telemetry.
|
||||
/// </summary>
|
||||
public ZB.MOM.WW.LmxOpcUa.Host.OpcUa.LmxNodeManager? NodeManager => Service.NodeManagerInstance;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,50 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
|
||||
html.ShouldContain("<h2>Alarms</h2>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Endpoints panel renders in the HTML dashboard even when no server host has been set,
|
||||
/// so operators can tell the OPC UA server has not started.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_IncludesEndpointsPanel()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
|
||||
html.ShouldContain("<h2>Endpoints</h2>");
|
||||
html.ShouldContain("OPC UA server not started");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard JSON surfaces the alarm filter counters so monitoring clients can verify scope.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateJson_Alarms_IncludesFilterCounters()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var json = sut.GenerateJson();
|
||||
|
||||
json.ShouldContain("FilterEnabled");
|
||||
json.ShouldContain("FilterPatternCount");
|
||||
json.ShouldContain("FilterIncludedObjectCount");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard JSON surfaces the Endpoints section with base-address and security-profile slots
|
||||
/// so monitoring clients can read them programmatically.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateJson_Endpoints_IncludesBaseAddressesAndSecurityProfiles()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var json = sut.GenerateJson();
|
||||
|
||||
json.ShouldContain("Endpoints");
|
||||
json.ShouldContain("BaseAddresses");
|
||||
json.ShouldContain("SecurityProfiles");
|
||||
json.ShouldContain("UserTokenPolicies");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The /api/health payload exposes Historian and Alarms component status.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user