/* ========================================================================
* 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;
}
}
}