/* ======================================================================== * 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 System.Linq; using System.Text; using System.Xml; using Microsoft.Extensions.Logging; using NUnit.Framework; using Opc.Ua.Tests; using SchemaTypes = Opc.Ua.Schema.Types; namespace Opc.Ua.Schema.Model.Tests { /// /// Regression tests for defects in /// (identifier allocation, dependency load order, method argument /// properties, enum identifiers and dependency value ranks). /// [TestFixture] [Category("ModelDesign")] [SetCulture("en-us")] [SetUICulture("en-us")] public class ModelDesignValidatorRegressionTests { private VirtualFileSystem m_fileSystem; [SetUp] public void SetUp() { m_fileSystem = new VirtualFileSystem(); } [TearDown] public void TearDown() { m_fileSystem?.Dispose(); m_fileSystem = null!; } /// /// An explicit NumericId in the design must be reserved before /// identifiers are allocated, otherwise another node of the same /// namespace is handed the same number and validation fails with /// "Duplicate identifiers". /// [TestCase(true)] [TestCase(false)] public void ExplicitNumericIdIsNotHandedOutToAnotherNode(bool pinnedFirst) { const string pinned = """"""; const string auto = """"""; string path = AddDesign("Pinned", pinnedFirst ? pinned + auto : auto + pinned); ModelDesignValidator validator = CreateValidator(startId: 1); Assert.DoesNotThrow(() => validator.Validate([path], [], null!)); Assert.Multiple(() => { Assert.That(FindNode(validator, "PinnedType", "Pinned").NumericId, Is.EqualTo(1u)); Assert.That(FindNode(validator, "AutoType", "Pinned").NumericId, Is.EqualTo(2u)); }); } /// /// The dependency load order must be a topological order of the /// imports independent of the order the dependency files are passed /// in: Z imports X which imports Y (which the target imports too). /// [TestCase("Z", "X", "Y")] [TestCase("X", "Z", "Y")] [TestCase("Y", "Z", "X")] [TestCase("Z", "Y", "X")] public void DependenciesLoadInImportOrderRegardlessOfFileOrder( string first, string second, string third) { var files = new Dictionary { ["Y"] = AddDesign( "Y", """"""), ["X"] = AddDesign( "X", """""", "Y"), ["Z"] = AddDesign( "Z", """""", "X") }; string target = AddDesign( "T", """""", "Y"); ModelDesignValidator validator = CreateValidator(); Assert.DoesNotThrow(() => validator.Validate( [target], [files[first], files[second], files[third]], null!)); var zType = (TypeDesign)FindNode(validator, "ZType", "Z"); Assert.That(zType.BaseTypeNode?.SymbolicName?.Name, Is.EqualTo("XType")); } /// /// A dependency that imports the primary target must still be loaded /// after it. /// [Test] public void DependencyImportingTheTargetLoadsAfterTheTarget() { string downstream = AddDesign( "D", """""", "T"); string target = AddDesign( "T", """"""); ModelDesignValidator validator = CreateValidator(); Assert.DoesNotThrow(() => validator.Validate([target], [downstream], null!)); var dType = (TypeDesign)FindNode(validator, "DType", "D"); Assert.That(dType.BaseTypeNode?.SymbolicName?.Name, Is.EqualTo("TType")); } /// /// OPC 10000-3 only defines the InputArguments / OutputArguments /// properties of a method when it has arguments of that direction. /// A child method without arguments must not grow empty argument /// properties (which consume NodeIds and are emitted as nodes). /// [Test] public void ChildMethodWithoutArgumentsHasNoArgumentProperties() { string path = AddDesign("Methods", MachineTypeWithMethods("MachineType", "ua:BaseObjectType")); ModelDesignValidator validator = CreateValidator(); validator.Validate([path], [], null!); var type = (TypeDesign)FindNode(validator, "MachineType", "Methods"); List paths = type.Hierarchy.NodeList.ConvertAll(n => n.RelativePath); Assert.Multiple(() => { Assert.That(paths, Does.Contain("Reset")); Assert.That(paths, Does.Not.Contain("Reset_InputArguments")); Assert.That(paths, Does.Not.Contain("Reset_OutputArguments")); Assert.That(paths, Does.Contain("Start_InputArguments")); Assert.That(paths, Does.Not.Contain("Start_OutputArguments")); Assert.That(paths, Does.Contain("Stop_OutputArguments")); Assert.That(paths, Does.Not.Contain("Stop_InputArguments")); }); } /// /// Same as /// for methods inherited from a type declared in a dependency design /// (linked by LinkDependencyInstances instead of ValidateInstance). /// [Test] public void InheritedDependencyMethodWithoutArgumentsHasNoArgumentProperties() { string dependency = AddDesign("Dep", MachineTypeWithMethods("DepMachineType", "ua:BaseObjectType")); string target = AddDesign( "Tgt", """""", "Dep"); ModelDesignValidator validator = CreateValidator(); validator.Validate([target], [dependency], null!); var type = (TypeDesign)FindNode(validator, "SubMachineType", "Tgt"); List paths = type.Hierarchy.NodeList.ConvertAll(n => n.RelativePath); Assert.Multiple(() => { Assert.That(paths, Does.Contain("Reset")); Assert.That(paths, Does.Not.Contain("Reset_InputArguments")); Assert.That(paths, Does.Not.Contain("Reset_OutputArguments")); Assert.That(paths, Does.Contain("Start_InputArguments")); Assert.That(paths, Does.Not.Contain("Start_OutputArguments")); Assert.That(paths, Does.Contain("Stop_OutputArguments")); Assert.That(paths, Does.Not.Contain("Stop_InputArguments")); }); } /// /// ALT-6: the argument properties are ensured per direction, in the /// target and the dependency path alike. A method that declares its /// InputArguments property explicitly keeps that one (the target path /// used to add a second) and still gets its OutputArguments property /// (the dependency path used to skip both when either existed). /// [TestCase(false)] [TestCase(true)] public void ExplicitArgumentPropertyIsKeptAndTheOtherDirectionAdded(bool fromDependency) { const string machine = """ """; ModelDesignValidator validator = CreateValidator(); string ns; if (fromDependency) { string dependency = AddDesign("Dep", machine); string target = AddDesign( "Tgt", """""", "Dep"); validator.Validate([target], [dependency], null!); ns = "Dep"; } else { validator.Validate([AddDesign("Movers", machine)], [], null!); ns = "Movers"; } var type = (TypeDesign)FindNode(validator, "MoverType", ns); var method = (MethodDesign)type.Children.Items.Single(c => c.SymbolicName.Name == "Move"); List names = [.. method.Children.Items.Select(c => c.SymbolicName.Name)]; Assert.Multiple(() => { Assert.That(names.Count(n => n == "InputArguments"), Is.EqualTo(1), string.Join(",", names)); Assert.That(names.Count(n => n == "OutputArguments"), Is.EqualTo(1), string.Join(",", names)); }); } /// /// The trailing "_<digits>" naming convention only supplies an /// enum value when none is authored: an explicit Identifier wins. /// [Test] public void ExplicitEnumIdentifierWinsOverTheNameSuffix() { string path = AddDesign( "Enums", """ """); ModelDesignValidator validator = CreateValidator(); validator.Validate([path], [], null!); var dataType = (DataTypeDesign)FindNode(validator, "TextEncoding", "Enums"); Assert.That( dataType.Fields.Select(f => f.Identifier), Is.EqualTo(new decimal[] { 0, 1, 2, 7, 8 })); } /// /// A child variable of a dependency type keeps its authored ValueRank /// instead of taking the rank of its default value. /// [Test] public void DependencyChildVariableKeepsItsAuthoredValueRank() { string dependency = AddDesign( "Dep", """ 0 0 """); string target = AddDesign( "Tgt", """""", "Dep"); ModelDesignValidator validator = CreateValidator(); validator.Validate([target], [dependency], null!); var type = (TypeDesign)FindNode(validator, "SubDeviceType", "Tgt"); var limits = (VariableDesign)type.Hierarchy.NodeList .Single(n => n.RelativePath == "Limits").Instance!; var level = (VariableDesign)type.Hierarchy.NodeList .Single(n => n.RelativePath == "Level").Instance!; Assert.Multiple(() => { Assert.That(limits!.ValueRank, Is.EqualTo(ValueRank.ScalarOrArray)); Assert.That(level!.ValueRank, Is.EqualTo(ValueRank.Scalar)); }); } /// /// A method that references a method declaration through its /// TypeDefinition keeps its own authored description. /// [Test] public void MethodWithTypeDefinitionKeepsItsOwnDescription() { string path = AddDesign( "Pumps", """ Starts the device. Starts the pump. """); ModelDesignValidator validator = CreateValidator(); validator.Validate([path], [], null!); var type = (TypeDesign)FindNode(validator, "PumpType", "Pumps"); InstanceDesign start = type.Children.Items.Single(c => c.SymbolicName.Name == "Start"); InstanceDesign restart = type.Children.Items.Single(c => c.SymbolicName.Name == "Restart"); Assert.Multiple(() => { Assert.That(start.Description?.Value, Is.EqualTo("Starts the pump.")); Assert.That(restart.Description?.Value, Is.EqualTo("Starts the device.")); }); } /// /// An inherited child whose name merely starts with the name of an /// explicitly declared child (StatusCode vs Status) is not explicitly /// defined and takes its identifier from the implicit range. /// [Test] public void InheritedChildSharingANamePrefixIsNotExplicitlyDefined() { string path = AddDesign( "Prefix", """ """); ModelDesignValidator validator = CreateValidator(startId: 1); validator.Validate([path], [], null!); var type = (TypeDesign)FindNode(validator, "MachineType", "Prefix"); HierarchyNode status = type.Hierarchy.NodeList.Single(n => n.RelativePath == "Status"); HierarchyNode statusCode = type.Hierarchy.NodeList.Single(n => n.RelativePath == "StatusCode"); Assert.Multiple(() => { Assert.That((uint)status.Identifier!, Is.LessThan(1000000u)); Assert.That((uint)statusCode.Identifier!, Is.GreaterThanOrEqualTo(1000000u)); }); } /// /// The reference type of a child is validated: an unknown reference /// type is reported instead of silently flowing into generated code. /// [TestCase("ua:HasComponnet", true)] [TestCase("ua:HasOrderedComponent", false)] public void UnknownChildReferenceTypeIsReported(string referenceType, bool reported) { string path = AddDesign( "Refs", $""" {referenceType} """); var entries = new List(); ModelDesignValidator validator = CreateValidator( telemetry: new CapturingTelemetryContext(entries)); try { validator.Validate([path], [], null!); } catch (InvalidOperationException) { // a later stage may reject the unknown reference type too. } Assert.That( entries.Exists(e => e.Contains(referenceType[3..], StringComparison.Ordinal)), Is.EqualTo(reported)); } /// /// A matrix field of the built-in type dictionary keeps one /// dimension per rank. /// [TestCase(-1, ValueRank.Scalar, null)] [TestCase(1, ValueRank.Array, null)] [TestCase(2, ValueRank.OneOrMoreDimensions, "0,0")] [TestCase(3, ValueRank.OneOrMoreDimensions, "0,0,0")] public void ImportFieldKeepsTheRankOfMatrixFields( int rank, ValueRank expectedRank, string? expectedDimensions) { var field = new SchemaTypes.FieldType { Name = "Field", DataType = new XmlQualifiedName("Double"), ValueRank = rank }; Parameter parameter = ModelDesignValidator.ImportField(field)!; Assert.Multiple(() => { Assert.That(parameter!.ValueRank, Is.EqualTo(expectedRank)); Assert.That(parameter.ArrayDimensions, Is.EqualTo(expectedDimensions)); }); } private static string MachineTypeWithMethods(string name, string baseType) { return $""" """; } private static string NamespaceUri(string name) { return $"http://test.org/UA/{name}/"; } private string AddDesign(string name, string body, params string[] imports) { var design = new StringBuilder(); design.Append("\n") .Append("\n") .Append(" \n") .Append(" ") .Append("http://opcfoundation.org/UA/\n"); foreach (string ns in imports.Prepend(name)) { design.Append(" ") .Append(NamespaceUri(ns)).Append("\n"); } design.Append(" \n") .Append(body).Append('\n') .Append("\n"); string path = $"memory://{name}.design.xml"; m_fileSystem.Add(path, Encoding.UTF8.GetBytes(design.ToString())); return path; } private static NodeDesign FindNode(ModelDesignValidator validator, string name, string ns) { Assert.That( validator.TryFindNode( new XmlQualifiedName(name, NamespaceUri(ns)), "Test", "Node", out NodeDesign? node), Is.True, name); return node!; } private ModelDesignValidator CreateValidator( uint startId = 1000, ITelemetryContext? telemetry = null) { telemetry ??= NUnitTelemetryContext.Create(logLevel: LogLevel.Error); IFileSystem fileSystem = typeof(ModelDesignValidator).Assembly .AsFileSystem("Opc.Ua.SourceGeneration.Design") .WithFallback(m_fileSystem); return new ModelDesignValidator( fileSystem, startId, null!, telemetry, SpecificationVersion.V105); } private sealed class CapturingTelemetryContext : TelemetryContextBase { public CapturingTelemetryContext(List entries) : base(new CapturingLoggerFactory(entries)) { } } private sealed class CapturingLoggerFactory : ILoggerFactory { public CapturingLoggerFactory(List entries) { m_logger = new CapturingLogger(entries); } public void AddProvider(ILoggerProvider provider) { } public ILogger CreateLogger(string categoryName) { return m_logger; } public void Dispose() { } private readonly CapturingLogger m_logger; } private sealed class CapturingLogger : ILogger { public CapturingLogger(List entries) { m_entries = entries; } public IDisposable BeginScope(TState state) where TState : notnull { return null!; } public bool IsEnabled(LogLevel logLevel) { return logLevel >= LogLevel.Warning; } public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (IsEnabled(logLevel)) { m_entries.Add(formatter(state, exception!)); } } private readonly List m_entries; } } }