using Opc.Ua;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
///
/// Regression for Server-008 — RouteScriptedAlarmMethodCalls must mark a handled
/// slot as Processed = true so the stack's
/// CustomNodeManager2.Call skips it. The pre-fix code relied on the slot's
/// errors[i] being ServiceResult.Good, but the SDK's actual skip predicate is
/// ; without setting it, the stack's built-in
/// Part 9 Acknowledge / Confirm handler would also fire, producing a double transition.
///
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmMethodRoutingProcessedFlagTests
{
private static ScriptedAlarmEngine BuildActiveEngine(string alarmId)
{
var upstream = new CachedTagUpstreamSource();
var logger = new LoggerConfiguration().CreateLogger();
var factory = new ScriptLoggerFactory(logger);
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
var defs = new List
{
new(AlarmId: alarmId,
EquipmentPath: "/eq",
AlarmName: alarmId,
Kind: AlarmKind.LimitAlarm,
Severity: AlarmSeverity.Medium,
MessageTemplate: "msg",
PredicateScriptSource: "return true;"),
};
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
return engine;
}
private static ScriptedAlarmEngine BuildEngine(string alarmId)
{
var upstream = new CachedTagUpstreamSource();
var logger = new LoggerConfiguration().CreateLogger();
var factory = new ScriptLoggerFactory(logger);
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
var defs = new List
{
new(AlarmId: alarmId,
EquipmentPath: "/eq",
AlarmName: alarmId,
Kind: AlarmKind.LimitAlarm,
Severity: AlarmSeverity.Medium,
MessageTemplate: "msg",
PredicateScriptSource: "return false;"),
};
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
return engine;
}
private static CallMethodRequest AcknowledgeRequest(string conditionNodeId)
=> new()
{
ObjectId = new NodeId(conditionNodeId, 2),
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
InputArguments =
{
new Variant(new byte[] { 1, 2, 3 }),
new Variant(new LocalizedText("ack-comment")),
},
};
private static CallMethodRequest AddCommentRequest(string conditionNodeId)
=> new()
{
ObjectId = new NodeId(conditionNodeId, 2),
MethodId = MethodIds.ConditionType_AddComment,
InputArguments =
{
new Variant(new byte[] { 1, 2, 3 }),
new Variant(new LocalizedText("comment-text")),
},
};
[Fact]
public void Handled_Acknowledge_marks_Processed_true_so_baseCall_skips_the_slot()
{
using var engine = BuildActiveEngine("al-1");
var calls = new List { AcknowledgeRequest("al-1.Condition") };
var results = new List { new CallMethodResult() };
var errors = new List { null! };
var index = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["al-1.Condition"] = "al-1",
};
DriverNodeManager.RouteScriptedAlarmMethodCalls(
new NamedIdentity("ops-user"), calls, results, errors, engine, index);
calls[0].Processed.ShouldBeTrue(
"CustomNodeManager2.Call/CallInternalAsync skips slots with Processed=true. "
+ "Without this flag, base.Call would re-dispatch the Acknowledge to the stack's "
+ "built-in Part 9 handler and the engine would observe a double transition.");
}
[Fact]
public void Handled_AddComment_marks_Processed_true()
{
using var engine = BuildEngine("al-1");
var calls = new List { AddCommentRequest("al-1.Condition") };
var results = new List { new CallMethodResult() };
var errors = new List { null! };
var index = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["al-1.Condition"] = "al-1",
};
DriverNodeManager.RouteScriptedAlarmMethodCalls(
new NamedIdentity("ops-user"), calls, results, errors, engine, index);
calls[0].Processed.ShouldBeTrue("AddComment handled by the engine must not re-dispatch via base.Call");
}
[Fact]
public void Engine_error_path_also_marks_Processed_so_baseCall_does_not_re_run_the_method()
{
using var engine = BuildEngine("al-1");
var calls = new List
{
// Index maps to an alarm id the engine doesn't know — engine throws
// ArgumentException, helper sets errors[i] = BadInvalidArgument.
AcknowledgeRequest("al-1.Condition"),
};
var results = new List { new CallMethodResult() };
var errors = new List { null! };
var index = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["al-1.Condition"] = "al-NOT-IN-ENGINE",
};
DriverNodeManager.RouteScriptedAlarmMethodCalls(
new NamedIdentity("ops-user"), calls, results, errors, engine, index);
ServiceResult.IsBad(errors[0]).ShouldBeTrue("engine error path");
calls[0].Processed.ShouldBeTrue(
"even when the engine returns Bad, the slot was handled — base.Call must not "
+ "re-dispatch the method against the OPC UA built-in handler.");
}
[Fact]
public void Unhandled_slot_leaves_Processed_false_so_baseCall_drives_it()
{
using var engine = BuildActiveEngine("al-1");
var genericMethod = new CallMethodRequest
{
ObjectId = new NodeId("some-driver-method", 2),
MethodId = new NodeId("driver-method", 2),
};
var calls = new List { genericMethod };
var results = new List { new CallMethodResult() };
var errors = new List { null! };
DriverNodeManager.RouteScriptedAlarmMethodCalls(
new NamedIdentity("ops-user"), calls, results, errors, engine,
conditionIdToAlarmId: new Dictionary());
calls[0].Processed.ShouldBeFalse("non-alarm methods must fall through to base.Call");
errors[0].ShouldBeNull("unhandled slot's error must stay null for the base implementation");
}
private sealed class NamedIdentity(string displayName) : UserIdentity(displayName, "") { }
}