using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Guards the failure-visibility surface added for archreview 01/S-1: the applier must report a /// failed rebuild () and tally swallowed per-node /// sink failures ( for Apply's own passes; the /// Materialise* passes return their own failed-node counts) instead of reporting optimistic /// success while the running address space is stale or partial. /// public sealed class AddressSpaceApplierFailureSurfaceTests { // ---------------- T1: RebuildFailed ---------------- /// A rebuild whose sink call throws is reported as attempted-but-failed, not optimistic success. [Fact] public void Apply_WhenRebuildThrows_ReportsRebuildFailed() { var sink = new ConfigurableThrowingSink { ThrowOnRebuild = true }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var outcome = applier.Apply(AddedEquipmentPlan("new")); outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildFailed.ShouldBeTrue(); } /// A clean apply reports no failure on either channel — the regression guard for the new fields. [Fact] public void Apply_HappyPath_ReportsNoFailure() { var sink = new ConfigurableThrowingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var outcome = applier.Apply(AddedEquipmentPlan("new")); outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildFailed.ShouldBeFalse(); outcome.FailedNodes.ShouldBe(0); } // ---------------- T2: FailedNodes (removal-pass tally) ---------------- /// A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today). [Fact] public void Apply_WhenRemovalConditionWriteThrows_CountsFailedNodes() { var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var outcome = applier.Apply(EquipmentRemovalPlan("eq-1")); outcome.RemovedNodes.ShouldBe(1); outcome.FailedNodes.ShouldBe(1); } // ---------------- T3: Materialise* passes return failed-node counts ---------------- /// Every EnsureVariable throwing in MaterialiseEquipmentTags is tallied into the pass's int return. [Fact] public void MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount() { var sink = new ConfigurableThrowingSink { ThrowOnEnsureVariable = true }; var applier = new AddressSpaceApplier(sink, NullLogger.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); } /// All five Materialise* passes return 0 failures on a clean sink — the happy-path regression. [Fact] public void Materialise_HappyPath_AllPassesReturnZero() { var sink = new ConfigurableThrowingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.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(), Historize: false)); var alarms = CompositionWithScriptedAlarms( new EquipmentScriptedAlarmPlan("a1", "eq-1", "Alarm", "OffNormalAlarm", 500, "msg", "pred", "true", Array.Empty(), 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(), 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(), Array.Empty(), Array.Empty()) { EquipmentTags = tags, }; private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) => new(Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = vtags, }; private static AddressSpaceComposition CompositionWithScriptedAlarms(params EquipmentScriptedAlarmPlan[] alarms) => new(Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentScriptedAlarms = alarms, }; private static AddressSpacePlan AddedEquipmentPlan(string id) => new( AddedEquipment: new[] { new EquipmentNode(id, id, "line-1") }, RemovedEquipment: Array.Empty(), ChangedEquipment: Array.Empty(), AddedDrivers: Array.Empty(), RemovedDrivers: Array.Empty(), ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()); private static AddressSpacePlan EquipmentRemovalPlan(params string[] ids) => new( AddedEquipment: Array.Empty(), RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(), ChangedEquipment: Array.Empty(), AddedDrivers: Array.Empty(), RemovedDrivers: Array.Empty(), ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()); /// An 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. 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) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault"); } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault"); } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault"); } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, 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) { } } }