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);
}
// ---------------- fixtures ----------------
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) { }
}
}