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,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")
};
}
}