RouteScriptedAlarmMethodCalls now handles ConditionType.AddComment alongside Acknowledge/Confirm, dispatching to engine.AddCommentAsync. An empty comment is rejected by the Part 9 state machine and surfaced as BadInvalidArgument. MapCallOperation gates AddComment at the AlarmAcknowledge tier — there is no dedicated AddComment permission bit. Closes phase-7-status.md Gap 1: all Part 9 alarm methods now route to the engine. Adds 3 unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
571 lines
24 KiB
C#
571 lines
24 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Serilog;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
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;
|
|
using CoreAlarmConditionState = ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.AlarmConditionState;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
|
|
|
/// <summary>
|
|
/// Task #24 — Gap 1 of phase-7-status.md. Covers
|
|
/// <see cref="DriverNodeManager.RouteScriptedAlarmMethodCalls"/> which intercepts
|
|
/// OPC UA Part 9 Acknowledge / Confirm method invocations on scripted alarm condition
|
|
/// nodes and routes them to <see cref="ScriptedAlarmEngine"/>, and the
|
|
/// <see cref="Phase7ComposedSources.AlarmEngine"/> property added to expose the
|
|
/// engine through the composition chain.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ScriptedAlarmMethodRoutingTests
|
|
{
|
|
// -----------------------------------------------------------------------
|
|
// Phase7ComposedSources — AlarmEngine property
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Compose_ScriptedAlarm_rows_exposes_AlarmEngine()
|
|
{
|
|
var scripts = new[] { ScriptRow("scr-1", "return false;") };
|
|
var alarms = new[] { AlarmRow("al-1", "scr-1") };
|
|
|
|
var result = Phase7EngineComposer.Compose(
|
|
scripts, [], alarms,
|
|
upstream: new CachedTagUpstreamSource(),
|
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
|
historianSink: NullAlarmHistorianSink.Instance,
|
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
|
loggerFactory: NullLoggerFactory.Instance);
|
|
|
|
result.AlarmEngine.ShouldNotBeNull("engine is exposed so the server can route method calls");
|
|
result.ScriptedAlarmReadable.ShouldNotBeNull();
|
|
result.Disposables.Count.ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_empty_rows_AlarmEngine_is_null()
|
|
{
|
|
var result = Phase7EngineComposer.Compose(
|
|
scripts: [],
|
|
virtualTags: [],
|
|
scriptedAlarms: [],
|
|
upstream: new CachedTagUpstreamSource(),
|
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
|
historianSink: NullAlarmHistorianSink.Instance,
|
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
|
loggerFactory: NullLoggerFactory.Instance);
|
|
|
|
result.ShouldBeSameAs(Phase7ComposedSources.Empty);
|
|
result.AlarmEngine.ShouldBeNull("empty composition returns the Empty sentinel with all-null engines");
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_VirtualTag_only_AlarmEngine_is_null()
|
|
{
|
|
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
|
var vtags = new[] { VtRow("vt-1", "scr-1") };
|
|
|
|
var result = Phase7EngineComposer.Compose(
|
|
scripts, vtags, [],
|
|
upstream: new CachedTagUpstreamSource(),
|
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
|
historianSink: NullAlarmHistorianSink.Instance,
|
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
|
loggerFactory: NullLoggerFactory.Instance);
|
|
|
|
result.AlarmEngine.ShouldBeNull("no scripted alarms → alarm engine is null");
|
|
result.VirtualReadable.ShouldNotBeNull();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// RouteScriptedAlarmMethodCalls — pure-function dispatch kernel
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Builds a loaded ScriptedAlarmEngine with the given alarm IDs.
|
|
/// All predicates return <c>false</c> so the alarm starts Inactive.
|
|
/// </summary>
|
|
private static ScriptedAlarmEngine BuildEngine(params string[] alarmIds)
|
|
{
|
|
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 = alarmIds.Select(id => new ScriptedAlarmDefinition(
|
|
AlarmId: id,
|
|
EquipmentPath: "/eq",
|
|
AlarmName: id,
|
|
Kind: AlarmKind.LimitAlarm,
|
|
Severity: AlarmSeverity.Medium,
|
|
MessageTemplate: "msg",
|
|
PredicateScriptSource: "return false;")).ToList();
|
|
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
|
return engine;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a loaded ScriptedAlarmEngine where the named alarm starts Active
|
|
/// (predicate = return true) so subsequent Acknowledge tests have an
|
|
/// Unacknowledged state to advance.
|
|
/// </summary>
|
|
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<ScriptedAlarmDefinition>
|
|
{
|
|
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 IUserIdentity? MakeIdentity(string? displayName)
|
|
=> displayName is null ? null : new NamedUserIdentity(displayName);
|
|
|
|
private static CallMethodRequest AcknowledgeRequest(string conditionNodeId, string? comment = null)
|
|
=> new()
|
|
{
|
|
ObjectId = new NodeId(conditionNodeId, 2),
|
|
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
|
InputArguments = new VariantCollection
|
|
{
|
|
new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored
|
|
new Variant(new LocalizedText(comment ?? string.Empty)),
|
|
},
|
|
};
|
|
|
|
private static CallMethodRequest ConfirmRequest(string conditionNodeId, string? comment = null)
|
|
=> new()
|
|
{
|
|
ObjectId = new NodeId(conditionNodeId, 2),
|
|
MethodId = MethodIds.AcknowledgeableConditionType_Confirm,
|
|
InputArguments = new VariantCollection
|
|
{
|
|
new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored
|
|
new Variant(new LocalizedText(comment ?? string.Empty)),
|
|
},
|
|
};
|
|
|
|
private static CallMethodRequest AddCommentRequest(string conditionNodeId, string comment)
|
|
=> new()
|
|
{
|
|
ObjectId = new NodeId(conditionNodeId, 2),
|
|
MethodId = MethodIds.ConditionType_AddComment,
|
|
InputArguments = new VariantCollection
|
|
{
|
|
new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored
|
|
new Variant(new LocalizedText(comment)),
|
|
},
|
|
};
|
|
|
|
private static CallMethodRequest GenericRequest(string objectNodeId)
|
|
=> new()
|
|
{
|
|
ObjectId = new NodeId(objectNodeId, 2),
|
|
MethodId = new NodeId("driver-method", 2),
|
|
};
|
|
|
|
private static Dictionary<string, string> Index(params (string condId, string alarmId)[] entries)
|
|
=> entries.ToDictionary(e => e.condId, e => e.alarmId, StringComparer.OrdinalIgnoreCase);
|
|
|
|
// ---- no-op paths -------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void No_index_entries_leaves_all_slots_untouched()
|
|
{
|
|
using var engine = BuildActiveEngine("al-1");
|
|
var calls = new List<CallMethodRequest>
|
|
{
|
|
AcknowledgeRequest("al-1.Condition"),
|
|
};
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var errors = new List<ServiceResult> { null! };
|
|
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("alice"), calls, results, errors, engine,
|
|
conditionIdToAlarmId: new Dictionary<string, string>());
|
|
|
|
errors[0].ShouldBeNull("no matching entry → slot left for base.Call");
|
|
}
|
|
|
|
[Fact]
|
|
public void Non_alarm_method_id_is_ignored()
|
|
{
|
|
using var engine = BuildEngine("al-1");
|
|
var calls = new List<CallMethodRequest>
|
|
{
|
|
GenericRequest("al-1.Condition"),
|
|
};
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var errors = new List<ServiceResult> { null! };
|
|
var index = Index(("al-1.Condition", "al-1"));
|
|
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
|
|
|
errors[0].ShouldBeNull("non-Acknowledge/Confirm methods pass through untouched");
|
|
}
|
|
|
|
[Fact]
|
|
public void Already_errored_slot_is_skipped()
|
|
{
|
|
using var engine = BuildActiveEngine("al-1");
|
|
var calls = new List<CallMethodRequest>
|
|
{
|
|
AcknowledgeRequest("al-1.Condition"),
|
|
};
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var priorError = new ServiceResult(StatusCodes.BadUserAccessDenied);
|
|
var errors = new List<ServiceResult> { priorError };
|
|
var index = Index(("al-1.Condition", "al-1"));
|
|
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
|
|
|
// Pre-populated bad error must not be overwritten.
|
|
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied);
|
|
}
|
|
|
|
// ---- Acknowledge -------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Acknowledge_on_active_alarm_advances_engine_state()
|
|
{
|
|
using var engine = BuildActiveEngine("al-1");
|
|
|
|
// Sanity: alarm must start unacknowledged after activation.
|
|
engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
|
|
|
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition", "looks ok") };
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var errors = new List<ServiceResult> { null! };
|
|
var index = Index(("al-1.Condition", "al-1"));
|
|
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("ops-user"), calls, results, errors, engine, index);
|
|
|
|
errors[0].ShouldNotBeNull();
|
|
ServiceResult.IsBad(errors[0]).ShouldBeFalse("Acknowledge succeeded");
|
|
engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
|
engine.GetState("al-1")!.LastAckUser.ShouldBe("ops-user");
|
|
engine.GetState("al-1")!.LastAckComment.ShouldBe("looks ok");
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_uses_opcua_client_as_fallback_when_identity_is_null()
|
|
{
|
|
using var engine = BuildActiveEngine("al-1");
|
|
|
|
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition") };
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var errors = new List<ServiceResult> { null! };
|
|
var index = Index(("al-1.Condition", "al-1"));
|
|
|
|
// Pass null identity (anonymous session).
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
userIdentity: null, calls, results, errors, engine, index);
|
|
|
|
engine.GetState("al-1")!.LastAckUser.ShouldBe("opcua-client");
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_with_no_input_arguments_uses_null_comment()
|
|
{
|
|
using var engine = BuildActiveEngine("al-1");
|
|
// Build a request without InputArguments to simulate a client that omits the comment.
|
|
var requestNoArgs = new CallMethodRequest
|
|
{
|
|
ObjectId = new NodeId("al-1.Condition", 2),
|
|
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
|
};
|
|
|
|
var calls = new List<CallMethodRequest> { requestNoArgs };
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var errors = new List<ServiceResult> { null! };
|
|
var index = Index(("al-1.Condition", "al-1"));
|
|
|
|
// Should not throw — comment defaults to null.
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
|
|
|
ServiceResult.IsBad(errors[0]).ShouldBeFalse("Acknowledge without comment succeeds");
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_marks_slot_result_as_Good_and_error_as_Good()
|
|
{
|
|
using var engine = BuildActiveEngine("al-1");
|
|
|
|
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition") };
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var errors = new List<ServiceResult> { null! };
|
|
var index = Index(("al-1.Condition", "al-1"));
|
|
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
|
|
|
results[0].StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
|
errors[0].ShouldBe(ServiceResult.Good);
|
|
}
|
|
|
|
// ---- Confirm -----------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Confirm_on_alarm_with_unconfirmed_state_advances_state()
|
|
{
|
|
// Build an alarm pre-seeded as Inactive + Acknowledged + Unconfirmed so
|
|
// ApplyConfirm has a valid transition to execute.
|
|
var store = new InMemoryAlarmStateStore();
|
|
var upstream = new CachedTagUpstreamSource();
|
|
var logger = new LoggerConfiguration().CreateLogger();
|
|
var factory = new ScriptLoggerFactory(logger);
|
|
var engine = new ScriptedAlarmEngine(upstream, store, factory, logger);
|
|
|
|
var seedState = CoreAlarmConditionState.Fresh("confirm-alarm", DateTime.UtcNow) with
|
|
{
|
|
Active = AlarmActiveState.Inactive,
|
|
Acked = AlarmAckedState.Acknowledged,
|
|
Confirmed = AlarmConfirmedState.Unconfirmed,
|
|
};
|
|
store.SaveAsync(seedState, CancellationToken.None).GetAwaiter().GetResult();
|
|
|
|
var defs = new List<ScriptedAlarmDefinition>
|
|
{
|
|
new("confirm-alarm", "/eq", "confirm-alarm", AlarmKind.LimitAlarm,
|
|
AlarmSeverity.Low, "msg", "return false;"),
|
|
};
|
|
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
|
|
|
engine.GetState("confirm-alarm")!.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
|
|
|
|
var calls = new List<CallMethodRequest>
|
|
{
|
|
ConfirmRequest("confirm-alarm.Condition", "all clear"),
|
|
};
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var errors = new List<ServiceResult> { null! };
|
|
var index = Index(("confirm-alarm.Condition", "confirm-alarm"));
|
|
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("ops-user"), calls, results, errors, engine, index);
|
|
|
|
ServiceResult.IsBad(errors[0]).ShouldBeFalse("Confirm succeeded");
|
|
engine.GetState("confirm-alarm")!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
|
|
engine.GetState("confirm-alarm")!.LastConfirmUser.ShouldBe("ops-user");
|
|
}
|
|
|
|
// ---- AddComment --------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void AddComment_appends_comment_to_engine_state()
|
|
{
|
|
using var engine = BuildEngine("al-1");
|
|
|
|
var calls = new List<CallMethodRequest> { AddCommentRequest("al-1.Condition", "checked the line") };
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var errors = new List<ServiceResult> { null! };
|
|
var index = Index(("al-1.Condition", "al-1"));
|
|
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("ops-user"), calls, results, errors, engine, index);
|
|
|
|
ServiceResult.IsBad(errors[0]).ShouldBeFalse("AddComment handled");
|
|
results[0].StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
|
var last = engine.GetState("al-1")!.Comments[^1];
|
|
last.Kind.ShouldBe("AddComment");
|
|
last.Text.ShouldBe("checked the line");
|
|
last.User.ShouldBe("ops-user");
|
|
}
|
|
|
|
[Fact]
|
|
public void AddComment_with_empty_text_returns_BadInvalidArgument()
|
|
{
|
|
using var engine = BuildEngine("al-1");
|
|
|
|
// The Part 9 state machine rejects an empty comment — surfaced as BadInvalidArgument.
|
|
var calls = new List<CallMethodRequest> { AddCommentRequest("al-1.Condition", "") };
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var errors = new List<ServiceResult> { null! };
|
|
var index = Index(("al-1.Condition", "al-1"));
|
|
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("ops-user"), calls, results, errors, engine, index);
|
|
|
|
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
|
|
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
|
}
|
|
|
|
// ---- Mixed batches -----------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Mixed_batch_handles_each_slot_independently()
|
|
{
|
|
using var engine = BuildActiveEngine("al-1");
|
|
|
|
var calls = new List<CallMethodRequest>
|
|
{
|
|
AcknowledgeRequest("al-1.Condition"), // scripted alarm → handled
|
|
GenericRequest("some-driver-method"), // non-alarm → pass through
|
|
AcknowledgeRequest("unknown-alarm.Condition"), // not in index → pass through
|
|
};
|
|
var results = Enumerable.Range(0, 3).Select(_ => new CallMethodResult()).ToList();
|
|
var errors = new List<ServiceResult> { null!, null!, null! };
|
|
var index = Index(("al-1.Condition", "al-1")); // only one entry
|
|
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
|
|
|
// Slot 0: Acknowledge on known scripted alarm → handled with Good result.
|
|
ServiceResult.IsBad(errors[0]).ShouldBeFalse("scripted alarm Acknowledge handled");
|
|
engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
|
|
|
// Slot 1: Generic method → left null for base.Call.
|
|
errors[1].ShouldBeNull("generic method left for base.Call");
|
|
|
|
// Slot 2: Unknown alarm id → left null for base.Call.
|
|
errors[2].ShouldBeNull("unknown condition id left for base.Call");
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_alarm_id_in_engine_returns_BadInvalidArgument()
|
|
{
|
|
using var engine = BuildEngine("al-1");
|
|
|
|
// The index says al-999 maps to "al-999-engine" but the engine has no such alarm.
|
|
var calls = new List<CallMethodRequest>
|
|
{
|
|
AcknowledgeRequest("al-999.Condition"),
|
|
};
|
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
|
var errors = new List<ServiceResult> { null! };
|
|
// Put a deliberately wrong alarmId in the index (engine will throw ArgumentException).
|
|
var index = Index(("al-999.Condition", "al-999-not-in-engine"));
|
|
|
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
|
|
|
ServiceResult.IsBad(errors[0]).ShouldBeTrue("unknown alarm in engine → error result");
|
|
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
|
}
|
|
|
|
// ---- Shelve routing (Task #24 follow-up) -------------------------------
|
|
|
|
[Fact]
|
|
public void InvokeEngineShelve_oneshot_shelves_engine_state()
|
|
{
|
|
using var engine = BuildEngine("al-1");
|
|
|
|
var result = DriverNodeManager.InvokeEngineShelve(
|
|
engine, "al-1", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
|
|
|
|
ServiceResult.IsBad(result).ShouldBeFalse("OneShotShelve succeeds");
|
|
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
|
|
}
|
|
|
|
[Fact]
|
|
public void InvokeEngineShelve_timed_shelves_engine_state()
|
|
{
|
|
using var engine = BuildEngine("al-1");
|
|
|
|
// shelvingTime is a Duration in ms — InvokeEngineShelve adds it to UtcNow.
|
|
var result = DriverNodeManager.InvokeEngineShelve(
|
|
engine, "al-1", "ops-user", shelving: true, oneShot: false, shelvingTime: 60_000, logger: null);
|
|
|
|
ServiceResult.IsBad(result).ShouldBeFalse("TimedShelve succeeds");
|
|
var state = engine.GetState("al-1")!;
|
|
state.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
|
|
state.Shelving.UnshelveAtUtc.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void InvokeEngineShelve_unshelve_clears_engine_state()
|
|
{
|
|
using var engine = BuildEngine("al-1");
|
|
DriverNodeManager.InvokeEngineShelve(
|
|
engine, "al-1", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
|
|
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
|
|
|
|
var result = DriverNodeManager.InvokeEngineShelve(
|
|
engine, "al-1", "ops-user", shelving: false, oneShot: false, shelvingTime: 0, logger: null);
|
|
|
|
ServiceResult.IsBad(result).ShouldBeFalse("Unshelve succeeds");
|
|
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
|
}
|
|
|
|
[Fact]
|
|
public void InvokeEngineShelve_timed_with_non_positive_duration_returns_BadInvalidArgument()
|
|
{
|
|
using var engine = BuildEngine("al-1");
|
|
|
|
// A TimedShelve resolving to an unshelve time at-or-before now is rejected by the
|
|
// engine's Part 9 state machine (ArgumentOutOfRangeException → BadInvalidArgument).
|
|
var result = DriverNodeManager.InvokeEngineShelve(
|
|
engine, "al-1", "ops-user", shelving: true, oneShot: false, shelvingTime: 0, logger: null);
|
|
|
|
ServiceResult.IsBad(result).ShouldBeTrue();
|
|
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
|
}
|
|
|
|
[Fact]
|
|
public void InvokeEngineShelve_unknown_alarm_returns_BadInvalidArgument()
|
|
{
|
|
using var engine = BuildEngine("al-1");
|
|
|
|
var result = DriverNodeManager.InvokeEngineShelve(
|
|
engine, "not-an-alarm", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
|
|
|
|
ServiceResult.IsBad(result).ShouldBeTrue("unknown alarm id → error result");
|
|
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
|
}
|
|
|
|
// ---- Phase7ComposedSources helpers -------------------------------------
|
|
|
|
private static Script ScriptRow(string id, string source) => new()
|
|
{
|
|
ScriptRowId = Guid.NewGuid(), GenerationId = 1,
|
|
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h",
|
|
};
|
|
|
|
private static VirtualTag VtRow(string id, string scriptId) => new()
|
|
{
|
|
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
|
VirtualTagId = id, EquipmentId = "eq-1", Name = id,
|
|
DataType = "Float32", ScriptId = scriptId,
|
|
};
|
|
|
|
private static ScriptedAlarm AlarmRow(string id, string scriptId) => new()
|
|
{
|
|
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
|
ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id,
|
|
AlarmType = "LimitAlarm", Severity = 500,
|
|
MessageTemplate = "x", PredicateScriptId = scriptId,
|
|
};
|
|
|
|
// ---- Fake user identity ------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Simple <see cref="UserIdentity"/> with a display name for unit testing.
|
|
/// Uses the <c>UserIdentity(username, password)</c> constructor so the base-class
|
|
/// <see cref="UserIdentity.DisplayName"/> property returns the supplied name when
|
|
/// accessed through the <see cref="IUserIdentity"/> interface.
|
|
/// The real production identity is <c>OtOpcUaServer.RoleBasedIdentity</c> which
|
|
/// populates DisplayName from the LDAP authentication result.
|
|
/// </summary>
|
|
private sealed class NamedUserIdentity(string displayName) : UserIdentity(displayName, "") { }
|
|
}
|