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

View File

@@ -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;
}
}

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
};
}
}

View File

@@ -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>