Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
T
Joseph Doherty 8ebc712eff feat(v3-batch4-wp4): multi-notifier native alarms (single ReportEvent → raw + equipment notifiers) + teardown symmetry
Materialize each native alarm ONCE at the raw tag (ConditionId = RawPath, Raw
realm); wire the single condition as an SDK event notifier of each referencing
equipment's UNS folder so one ReportEvent fans to every root without
re-reporting per root (which would break Server-object dedup + Part 9 ack
correlation).

- New sink method WireAlarmNotifiers(alarmNodeId, alarmRealm,
  notifierFolderNodeIds, notifierFolderRealm) on IOpcUaAddressSpaceSink,
  forwarded through DeferredAddressSpaceSink + SdkAddressSpaceSink +
  NullOpcUaAddressSpaceSink (the forwarding-trap guard); auto-covered by the
  DeferredSinkForwardingReflectionTests realm + forwarding guards + a
  hand-written forward test.
- OtOpcUaNodeManager: the normative AddNotifier(isInverse) pair + idempotent
  EnsureFolderIsEventNotifier per equipment folder; tracked per condition in
  _alarmNotifierWiring. Teardown symmetry: RemoveNotifier(bidirectional:true) on
  RebuildAddressSpace, RemoveAlarmConditionNode, and RemoveEquipmentSubtree so no
  inverse-notifier entry leaks across redeploys.
- AddressSpaceApplier.MaterialiseRawSubtree wires notifiers for each native alarm
  tag, resolving its ReferencingEquipmentPaths (Area/Line/Equipment) to the
  EquipmentId folder NodeIds via BuildEquipmentIdByFolderPath.
- AlarmTransitionEvent gains ReferencingEquipmentPaths (empty default); /alerts
  renders the referencing-equipment list as display metadata.
- Un-skipped + rewrote the native-alarm dark tests
  (DriverHostActorNativeAlarmTests x6, DriverHostActorNativeAlarmAckRoutingTests
  x1) for the v3 raw-condition model; new NodeManagerMultiNotifierAlarmTests
  proves multi-notifier wiring + teardown symmetry (no leaked duplicates after a
  re-trip) + applier wiring test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 12:07:11 -04:00

196 lines
10 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Guards the failure-visibility surface added for archreview 01/S-1: the applier must report a
/// failed rebuild (<see cref="AddressSpaceApplyOutcome.RebuildFailed"/>) and tally swallowed per-node
/// sink failures (<see cref="AddressSpaceApplyOutcome.FailedNodes"/> for Apply's own passes; the
/// <c>Materialise*</c> passes return their own failed-node counts) instead of reporting optimistic
/// success while the running address space is stale or partial.
/// </summary>
public sealed class AddressSpaceApplierFailureSurfaceTests
{
// ---------------- T1: RebuildFailed ----------------
/// <summary>A rebuild whose sink call throws is reported as attempted-but-failed, not optimistic success.</summary>
[Fact]
public void Apply_WhenRebuildThrows_ReportsRebuildFailed()
{
var sink = new ConfigurableThrowingSink { ThrowOnRebuild = true };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var outcome = applier.Apply(RebuildingPlan("eq-1"));
outcome.RebuildCalled.ShouldBeTrue();
outcome.RebuildFailed.ShouldBeTrue();
}
/// <summary>A clean apply reports no failure on either channel — the regression guard for the new fields.</summary>
[Fact]
public void Apply_HappyPath_ReportsNoFailure()
{
var sink = new ConfigurableThrowingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var outcome = applier.Apply(RebuildingPlan("eq-1"));
outcome.RebuildCalled.ShouldBeTrue();
outcome.RebuildFailed.ShouldBeFalse();
outcome.FailedNodes.ShouldBe(0);
}
// ---------------- T2: FailedNodes (removal-pass tally) ----------------
/// <summary>A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today).</summary>
[Fact]
public void Apply_WhenRemovalConditionWriteThrows_CountsFailedNodes()
{
var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var outcome = applier.Apply(EquipmentRemovalPlan("eq-1"));
outcome.RemovedNodes.ShouldBe(1);
outcome.FailedNodes.ShouldBe(1);
}
// ---------------- T3: Materialise* passes return failed-node counts ----------------
/// <summary>Every EnsureVariable throwing in MaterialiseEquipmentTags is tallied into the pass's int return.</summary>
[Fact]
public void MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount()
{
var sink = new ConfigurableThrowingSink { ThrowOnEnsureVariable = true };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var composition = CompositionWithTags(
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Float", FullName: "40003", Writable: false, Alarm: null));
applier.MaterialiseEquipmentTags(composition).ShouldBe(3);
}
/// <summary>All five Materialise* passes return 0 failures on a clean sink — the happy-path regression.</summary>
[Fact]
public void Materialise_HappyPath_AllPassesReturnZero()
{
var sink = new ConfigurableThrowingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var tags = CompositionWithTags(
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
var vtags = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("v1", "eq-1", FolderPath: "", Name: "VA", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty<string>(), Historize: false));
var alarms = CompositionWithScriptedAlarms(
new EquipmentScriptedAlarmPlan("a1", "eq-1", "Alarm", "OffNormalAlarm", 500, "msg", "pred", "true",
Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true));
applier.MaterialiseHierarchy(tags).ShouldBe(0);
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0);
applier.MaterialiseDiscoveredNodes(
"eq-1",
Array.Empty<DiscoveredFolder>(),
new[] { new DiscoveredVariable("eq-1/D", "eq-1", "D", "Float", Writable: false, IsArray: false, ArrayLength: null) })
.ShouldBe(0);
}
// ---------------- fixtures ----------------
private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
new(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = tags,
};
private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
new(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = vtags,
};
private static AddressSpaceComposition CompositionWithScriptedAlarms(params EquipmentScriptedAlarmPlan[] alarms) =>
new(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentScriptedAlarms = alarms,
};
// A rebuild-forcing plan: a node-affecting ChangedEquipment delta classifies as Rebuild (R2-07), so
// these tests exercise the SafeRebuild path (RebuildCalled/RebuildFailed) regardless of the pure-add
// routing. A pure-add plan would no longer rebuild, so the failure-surface tests must use a change.
private static AddressSpacePlan RebuildingPlan(string id) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: new[]
{
new AddressSpacePlan.EquipmentDelta(
new EquipmentNode(id, id, "line-1"),
new EquipmentNode(id, id + "-renamed", "line-1")),
},
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
private static AddressSpacePlan EquipmentRemovalPlan(params string[] ids) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
/// <summary>An <see cref="IOpcUaAddressSpaceSink"/> whose every sink call can be independently made to
/// throw, so tests can drive each Safe* helper's catch branch. Non-throwing calls are no-ops.</summary>
private sealed class ConfigurableThrowingSink : IOpcUaAddressSpaceSink
{
public bool ThrowOnRebuild { get; init; }
public bool ThrowOnEnsureVariable { get; init; }
public bool ThrowOnEnsureFolder { get; init; }
public bool ThrowOnAlarmWrite { get; init; }
public bool ThrowOnMaterialiseAlarm { get; init; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
}
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
{
if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault");
}
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault");
}
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
}
public void RebuildAddressSpace()
{
if (ThrowOnRebuild) throw new InvalidOperationException("simulated RebuildAddressSpace fault");
}
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}