/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using Moq; using NUnit.Framework; using Opc.Ua.Server.Fluent; #pragma warning disable CA2000 namespace Opc.Ua.Server.Tests.Fluent { /// /// Tests for the fluent state-machine builder exercising /// — a real stack-shipped /// subclass with full cause/transition/state tables. /// [TestFixture] [Category("Fluent")] public class StateMachineBuilderExtensionsTests { private const ushort kNs = 2; private static SystemContext CreateContext() { var ns = new NamespaceTable(); ns.Append(Ua.Namespaces.OpcUa); return new SystemContext(telemetry: null!) { NamespaceUris = ns }; } private static (NodeManagerBuilder Builder, ProgramStateMachineState Machine, MethodState StartMethod) CreateBuilder() { SystemContext ctx = CreateContext(); var machine = new ProgramStateMachineState(parent: null) { NodeId = new NodeId("Machine", kNs), BrowseName = new QualifiedName("Machine", kNs), DisplayName = new LocalizedText("Machine") }; machine.Create( ctx, machine.NodeId, machine.BrowseName, displayName: machine.DisplayName, assignNodeIds: false); var startMethod = new MethodState(machine) { NodeId = new NodeId("Machine_Start", kNs), BrowseName = new QualifiedName("Start", kNs), DisplayName = new LocalizedText("Start") }; machine.AddChild(startMethod); var roots = new Dictionary { [machine.BrowseName] = machine }; var byId = new Dictionary { [machine.NodeId] = machine, [startMethod.NodeId] = startMethod }; var builder = new NodeManagerBuilder( ctx, nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState? n) ? n! : null!, nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n! : null!, typeIdResolver: _ => []); return (builder, machine, startMethod); } private static IStateMachineBuilder ResolveMachine( NodeManagerBuilder builder) { return builder .Node(new NodeId("Machine", kNs)) .AsStateMachine(); } [Test] public void WithInitialStateSetsCurrentStateId() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); ResolveMachine(b).WithInitialState(Objects.ProgramStateMachineType_Ready); uint actual = ExtractCurrentStateId(m); Assert.That(actual, Is.EqualTo(Objects.ProgramStateMachineType_Ready)); } [Test] public void WithInitialStateZeroThrows() { (NodeManagerBuilder b, _, _) = CreateBuilder(); IStateMachineBuilder sb = ResolveMachine(b); Assert.Throws(() => sb.WithInitialState(0)); } [Test] public void OnEnterStateFiresOnArrival() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); ProgramStateMachineState? observed = null; ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .OnEnterState(Objects.ProgramStateMachineType_Running, (ctx, sm) => observed = sm); ServiceResult result = m.DoTransition( CreateContext(), Objects.ProgramStateMachineType_ReadyToRunning, causeId: 0, inputArguments: default, outputArguments: []); Assert.That(ServiceResult.IsGood(result), $"Transition failed: {result}"); Assert.That(observed, Is.SameAs(m)); } [Test] public void OnExitStateFiresWhenLeavingState() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); int exitFires = 0; ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .OnExitState(Objects.ProgramStateMachineType_Ready, (ctx, sm) => exitFires++); m.DoTransition( CreateContext(), Objects.ProgramStateMachineType_ReadyToRunning, causeId: 0, inputArguments: default, outputArguments: []); Assert.That(exitFires, Is.EqualTo(1)); } [Test] public void OnTransitionReceivesFromAndToStateIds() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); uint observedFrom = 0; uint observedTo = 0; ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .OnTransition((ctx, sm, from, to) => { observedFrom = from; observedTo = to; }); m.DoTransition( CreateContext(), Objects.ProgramStateMachineType_ReadyToRunning, causeId: 0, inputArguments: default, outputArguments: []); Assert.That(observedFrom, Is.EqualTo(Objects.ProgramStateMachineType_Ready)); Assert.That(observedTo, Is.EqualTo(Objects.ProgramStateMachineType_Running)); } [Test] public void OnBeforeTransitionGuardCancelsTransition() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .OnBeforeTransition((ctx, sm, fromStateId) => StatusCodes.BadUserAccessDenied); ServiceResult result = m.DoTransition( CreateContext(), Objects.ProgramStateMachineType_ReadyToRunning, causeId: 0, inputArguments: default, outputArguments: []); Assert.That(ServiceResult.IsBad(result)); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadUserAccessDenied)); // Should remain in Ready Assert.That(ExtractCurrentStateId(m), Is.EqualTo(Objects.ProgramStateMachineType_Ready)); } [Test] public void OnBeforeTransitionComposesWithExistingHandler() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); int existingCalls = 0; m.OnBeforeTransition = (ctx, sm, t, c, i, o) => { existingCalls++; return ServiceResult.Good; }; int fluentCalls = 0; ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .OnBeforeTransition((ctx, sm, fromStateId) => { fluentCalls++; return ServiceResult.Good; }); m.DoTransition( CreateContext(), Objects.ProgramStateMachineType_ReadyToRunning, causeId: 0, inputArguments: default, outputArguments: []); Assert.That(existingCalls, Is.EqualTo(1), "Pre-existing OnBeforeTransition handler must still run."); Assert.That(fluentCalls, Is.EqualTo(1), "Fluent guard must run after existing handler."); } [Test] public void FluentStateMachineBuilderRejectsInvalidCallbackArguments() { (NodeManagerBuilder b, _, _) = CreateBuilder(); IStateMachineBuilder sb = ResolveMachine(b); Assert.That(() => sb.OnEnterState(0, (ctx, sm) => { }), Throws.TypeOf()); Assert.That(() => sb.OnEnterState(1, null!), Throws.TypeOf()); Assert.That(() => sb.OnExitState(0, (ctx, sm) => { }), Throws.TypeOf()); Assert.That(() => sb.OnExitState(1, null!), Throws.TypeOf()); Assert.That(() => sb.OnTransition(null!), Throws.TypeOf()); Assert.That(() => sb.OnBeforeTransition(null!), Throws.TypeOf()); Assert.That(() => sb.ConfigureStateMachine(null!), Throws.TypeOf()); } [Test] public void WithCauseRejectsNullAndNonMethodNodes() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); IStateMachineBuilder sb = ResolveMachine(b); Assert.That( () => sb.WithCause(NodeId.Null, Methods.ProgramStateMachineType_Start), Throws.TypeOf()); ServiceResultException ex = Assert.Throws( () => sb.WithCause(m.NodeId, Methods.ProgramStateMachineType_Start))!; Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); } [Test] public void WithTimedTransitionRejectsInvalidArgumentsAndUnavailableRegistry() { (NodeManagerBuilder b, _, _) = CreateBuilder(); IStateMachineBuilder sb = ResolveMachine(b); Assert.That( () => sb.WithTimedTransition(0, TimeSpan.FromSeconds(1), 1), Throws.TypeOf()); Assert.That( () => sb.WithTimedTransition(1, TimeSpan.FromSeconds(1), 0), Throws.TypeOf()); Assert.That( () => sb.WithTimedTransition(1, TimeSpan.Zero, 1), Throws.TypeOf()); Assert.That( () => sb.WithTimedTransition(1, TimeSpan.FromSeconds(1), 1), Throws.TypeOf()); } [Test] public void ExistingBeforeTransitionFailureSkipsFluentGuard() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); int fluentCalls = 0; m.OnBeforeTransition = (ctx, sm, t, c, i, o) => StatusCodes.BadInvalidState; ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .OnBeforeTransition((ctx, sm, fromStateId) => { fluentCalls++; return ServiceResult.Good; }); ServiceResult result = m.DoTransition( CreateContext(), Objects.ProgramStateMachineType_ReadyToRunning, causeId: 0, inputArguments: default, outputArguments: []); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(fluentCalls, Is.Zero); } [Test] public void FluentGuardExceptionCancelsTransitionWithExceptionResult() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .OnBeforeTransition((ctx, sm, fromStateId) => throw new InvalidOperationException("guard")); ServiceResult result = m.DoTransition( CreateContext(), Objects.ProgramStateMachineType_ReadyToRunning, causeId: 0, inputArguments: default, outputArguments: []); Assert.That(ServiceResult.IsBad(result), Is.True); Assert.That(result.AdditionalInfo, Does.Contain("guard")); } [Test] public void ObserverCallbackExceptionsDoNotAbortTransition() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .OnExitState(Objects.ProgramStateMachineType_Ready, (ctx, sm) => throw new InvalidOperationException("exit")) .OnEnterState(Objects.ProgramStateMachineType_Running, (ctx, sm) => throw new InvalidOperationException("enter")) .OnTransition((ctx, sm, from, to) => throw new InvalidOperationException("transition")); ServiceResult result = m.DoTransition( CreateContext(), Objects.ProgramStateMachineType_ReadyToRunning, causeId: 0, inputArguments: default, outputArguments: []); Assert.That(ServiceResult.IsGood(result), Is.True); Assert.That(ExtractCurrentStateId(m), Is.EqualTo(Objects.ProgramStateMachineType_Running)); } [Test] public void WithCauseWiresMethodToDoCause() { (NodeManagerBuilder b, ProgramStateMachineState m, MethodState start) = CreateBuilder(); ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .WithCause( start.NodeId, Methods.ProgramStateMachineType_Start); Assert.That(start.OnCallMethod2, Is.Not.Null); ServiceResult result = start.OnCallMethod2!( CreateContext(), start, objectId: m.NodeId, inputArguments: default, outputArguments: []); Assert.That(ServiceResult.IsGood(result), $"DoCause via wired method must succeed: {result}"); Assert.That(ExtractCurrentStateId(m), Is.EqualTo(Objects.ProgramStateMachineType_Running)); } [Test] public void WithCauseReportsExecutableOnlyWhereTheCauseApplies() { (NodeManagerBuilder b, ProgramStateMachineState m, MethodState start) = CreateBuilder(); ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .WithCause(start.NodeId, Methods.ProgramStateMachineType_Start); // Start is a cause of Ready, so Part 3 §5.7 says the method // is executable there ... Assert.Multiple(() => { Assert.That(ReadAttribute(start, Attributes.Executable), Is.True); Assert.That(ReadAttribute(start, Attributes.UserExecutable), Is.True); }); m.SetState(CreateContext(), Objects.ProgramStateMachineType_Halted); // ... and not once the machine has halted, so a client can // see which causes apply instead of calling to find out. Assert.Multiple(() => { Assert.That(ReadAttribute(start, Attributes.Executable), Is.False); Assert.That(ReadAttribute(start, Attributes.UserExecutable), Is.False); }); } [Test] public void WithCauseLeavesTheAttributesAloneWhenOptedOut() { (NodeManagerBuilder b, ProgramStateMachineState m, MethodState start) = CreateBuilder(); start.Executable = true; start.UserExecutable = true; ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready) .WithCause( start.NodeId, Methods.ProgramStateMachineType_Start, reportExecutable: false); m.SetState(CreateContext(), Objects.ProgramStateMachineType_Halted); Assert.Multiple(() => { Assert.That(start.OnReadExecutable, Is.Null); Assert.That(start.OnReadUserExecutable, Is.Null); // The node keeps the value it was constructed with and // the refusal happens at call time instead. Assert.That(ReadAttribute(start, Attributes.Executable), Is.True); Assert.That(ReadAttribute(start, Attributes.UserExecutable), Is.True); }); } private static bool ReadAttribute(MethodState method, uint attributeId) { var value = new DataValue(); ServiceResult result = method.ReadAttribute( CreateContext(), attributeId, default, default, ref value); Assert.That(ServiceResult.IsGood(result), Is.True, $"read of {Attributes.GetBrowseName(attributeId)} failed: {result}"); Assert.That(value.WrappedValue.TryGetValue(out bool flag), Is.True, $"{Attributes.GetBrowseName(attributeId)} must read as a Boolean."); return flag; } [Test] public void WithCauseRejectsAlreadyWiredMethod() { (NodeManagerBuilder b, _, MethodState start) = CreateBuilder(); start.OnCallMethod2 = (ctx, m, oid, inputs, outputs) => ServiceResult.Good; IStateMachineBuilder sb = ResolveMachine(b) .WithInitialState(Objects.ProgramStateMachineType_Ready); ServiceResultException ex = Assert.Throws( () => sb.WithCause(start.NodeId, Methods.ProgramStateMachineType_Start))!; Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); } [Test] public void WithCauseUnknownMethodThrows() { (NodeManagerBuilder b, _, _) = CreateBuilder(); IStateMachineBuilder sb = ResolveMachine(b); Assert.Throws( () => sb.WithCause(new NodeId("UnknownMethod", kNs), Methods.ProgramStateMachineType_Start)); } [Test] public void ConfigureStateMachineExposesEscapeHatch() { (NodeManagerBuilder b, ProgramStateMachineState m, _) = CreateBuilder(); ResolveMachine(b) .ConfigureStateMachine(sm => sm.SuppressTransitionEvents = true); Assert.That(m.SuppressTransitionEvents, Is.True); } [Test] public void DoneReturnsOwningNodeBuilder() { (NodeManagerBuilder b, _, _) = CreateBuilder(); INodeBuilder nb = b .Node(new NodeId("Machine", kNs)); INodeBuilder returned = nb.AsStateMachine().Done(); Assert.That(returned, Is.SameAs(nb)); } [Test] public void CreateProgramStateMachineMaterialisesUnderParent() { (NodeManagerBuilder b, _, _) = CreateBuilder(); INodeBuilder parent = b.Node(new NodeId("Machine", kNs)); IStateMachineBuilder sm = parent.CreateProgramStateMachine(new QualifiedName("Cycle", kNs)); Assert.That(sm.StateMachine, Is.Not.Null); Assert.That(sm.StateMachine.BrowseName, Is.EqualTo(new QualifiedName("Cycle", kNs))); Assert.That(sm.StateMachine.Parent, Is.SameAs(parent.Node)); Assert.That(sm.StateMachine.NodeId.IdentifierAsString, Does.Contain("Machine").And.Contain("Cycle")); } [Test] public void CreateShelvedStateMachineMaterialisesUnderParent() { (NodeManagerBuilder b, _, _) = CreateBuilder(); INodeBuilder parent = b.Node(new NodeId("Machine", kNs)); IStateMachineBuilder sm = parent.CreateShelvedStateMachine(new QualifiedName("Shelv", kNs)); Assert.That(sm.StateMachine.BrowseName, Is.EqualTo(new QualifiedName("Shelv", kNs))); Assert.That(sm.StateMachine.Parent, Is.SameAs(parent.Node)); } [Test] public void CreateExclusiveLimitStateMachineMaterialisesUnderParent() { (NodeManagerBuilder b, _, _) = CreateBuilder(); INodeBuilder parent = b.Node(new NodeId("Machine", kNs)); IStateMachineBuilder sm = parent.CreateExclusiveLimitStateMachine( new QualifiedName("ExLim", kNs)); Assert.That(sm.StateMachine.BrowseName, Is.EqualTo(new QualifiedName("ExLim", kNs))); Assert.That(sm.StateMachine.Parent, Is.SameAs(parent.Node)); } [Test] public void CreateStateMachineRejectsNullArgs() { (NodeManagerBuilder b, _, _) = CreateBuilder(); INodeBuilder parent = b.Node(new NodeId("Machine", kNs)); Assert.Throws( () => parent.CreateProgramStateMachine(default)); } private static uint ExtractCurrentStateId(ProgramStateMachineState m) { NodeId value = m.CurrentState!.Id!.Value; if (value.IsNull) { return 0; } return value.TryGetValue(out uint numericId) ? numericId : 0; } } }