/* ======================================================================== * 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.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; using Opc.Ua.Server.Fluent; #pragma warning disable CA2000 namespace Opc.Ua.Server.Tests.Fluent { /// /// Direct tests for — the /// manager-owned periodic-loop registry. Exercises lifecycle /// (NewSimulation/Start/Dispose) and a real-clock smoke test /// to validate the /// integration. /// [TestFixture] [Category("Fluent")] public sealed class SimulationRegistryTests { [Test] public void ConstructorThrowsOnNullOwner() { ArgumentNullException ex = Assert.Throws( () => new SimulationRegistry(null!, NullLogger()))!; Assert.That(ex.ParamName, Is.EqualTo("owner")); } [Test] public void ConstructorAcceptsNullLogger() { using var registry = new SimulationRegistry( CreateOwner(), logger: null); Assert.That(registry, Is.Not.Null); } [Test] public void NewSimulationReturnsBuilder() { using var registry = new SimulationRegistry( CreateOwner(), NullLogger()); ISimulationBuilder builder = registry.NewSimulation( TimeSpan.FromMilliseconds(25)); Assert.That(builder, Is.Not.Null); } [Test] public void NewSimulationAfterStartThrowsBadInvalidState() { using var registry = new SimulationRegistry( CreateOwner(), NullLogger()); registry.Start(); ServiceResultException ex = Assert.Throws( () => registry.NewSimulation(TimeSpan.FromMilliseconds(25)))!; Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadInvalidState)); } [Test] public void StartIsIdempotent() { using var registry = new SimulationRegistry( CreateOwner(), NullLogger()); registry.Start(); // Second call must be a no-op (does not throw). Assert.DoesNotThrow(registry.Start); } [Test] public void DisposeWithoutStartIsSafe() { var registry = new SimulationRegistry( CreateOwner(), NullLogger()); Assert.DoesNotThrow(registry.Dispose); } [Test] public void DisposeIsIdempotent() { var registry = new SimulationRegistry( CreateOwner(), NullLogger()); registry.Start(); registry.Dispose(); // Second dispose must be a no-op (cts already cleared). Assert.DoesNotThrow(registry.Dispose); } [Test] [Category("Timing")] public async Task RegisteredLoopFiresAfterStart() { using var registry = new SimulationRegistry( CreateOwner(), NullLogger()); int ticks = 0; registry.NewSimulation(TimeSpan.FromMilliseconds(25)) .OnTick((ctx, dt) => Interlocked.Increment(ref ticks)); registry.Start(); await Task.Delay(200).ConfigureAwait(false); Assert.That(Volatile.Read(ref ticks), Is.GreaterThanOrEqualTo(1), "Tick handler must fire at least once within 200ms."); } [Test] [Category("Timing")] public async Task DisposeStopsFurtherTicks() { var registry = new SimulationRegistry( CreateOwner(), NullLogger()); int ticks = 0; registry.NewSimulation(TimeSpan.FromMilliseconds(25)) .OnTick((ctx, dt) => Interlocked.Increment(ref ticks)); registry.Start(); await Task.Delay(100).ConfigureAwait(false); registry.Dispose(); int snapshot = Volatile.Read(ref ticks); await Task.Delay(150).ConfigureAwait(false); int afterDispose = Volatile.Read(ref ticks); Assert.That(afterDispose, Is.LessThanOrEqualTo(snapshot + 1), "No (or at most one in-flight) tick may fire after Dispose."); } private static ILogger? NullLogger() { return null; } private static TestFluentManager CreateOwner() { return new TestFluentManager(); } private sealed class TestFluentManager : FluentNodeManagerBase { public TestFluentManager() : base(CreateMockServer(), TestNamespaceUri) { } private const string TestNamespaceUri = "urn:test:simulation-registry"; private static IServerInternal CreateMockServer() { var ns = new NamespaceTable(); ns.Append(Ua.Namespaces.OpcUa); var mockTelemetry = new Mock(); var mock = new Mock(); mock.SetupGet(m => m.NamespaceUris).Returns(ns); mock.SetupGet(m => m.Telemetry).Returns(mockTelemetry.Object); IServiceMessageContext msgCtx = ServiceMessageContext.Create( mockTelemetry.Object); mock.SetupGet(m => m.MessageContext).Returns(msgCtx); mock.SetupGet(m => m.DefaultSystemContext).Returns( new ServerSystemContext(mock.Object)); return mock.Object; } } } }