/* ======================================================================== * 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.Collections.Generic; using System.Linq; using System.Text; using Microsoft.Extensions.Logging; using NUnit.Framework; using Opc.Ua.Tests; namespace Opc.Ua.SourceGeneration { /// /// Tests for how resolves several /// NodeSet2 inputs that declare the same model URI. /// [TestFixture] [Category("SourceGeneration")] [SetCulture("en-us")] [SetUICulture("en-us")] public class NodesetFileCollectionTests { private const string ModelUri = "http://test.org/UA/Versioned/"; private VirtualFileSystem m_fileSystem; [SetUp] public void SetUp() { m_fileSystem = new VirtualFileSystem(); } [TearDown] public void TearDown() { m_fileSystem?.Dispose(); m_fileSystem = null!; } /// /// Regression: versions were compared as ordinal text, under which /// "1.05.9" sorts above "1.05.10", so the older NodeSet won. /// [TestCase("1.05.9", "1.05.10", "memory://second.NodeSet2.xml")] [TestCase("1.05.10", "1.05.9", "memory://first.NodeSet2.xml")] public void NewestVersionWinsRegardlessOfOrdinalOrdering( string firstVersion, string secondVersion, string expectedWinner) { const string first = "memory://first.NodeSet2.xml"; const string second = "memory://second.NodeSet2.xml"; m_fileSystem.Add(first, Encoding.UTF8.GetBytes(NodeSet(firstVersion))); m_fileSystem.Add(second, Encoding.UTF8.GetBytes(NodeSet(secondVersion))); NodesetFileCollection collection = Create( (first, firstVersion), (second, secondVersion)); Assert.That(collection.Files[ModelUri], Is.EqualTo(expectedWinner)); } /// /// Regression: a newer NodeSet read before an older one for the same /// model URI was overwritten by the older one unconditionally. /// [Test] public void NewestVersionWinsWhenItIsReadFirst() { const string newer = "memory://a-newer.NodeSet2.xml"; const string older = "memory://b-older.NodeSet2.xml"; m_fileSystem.Add(newer, Encoding.UTF8.GetBytes(NodeSet("2.0.0"))); m_fileSystem.Add(older, Encoding.UTF8.GetBytes(NodeSet("1.0.0"))); NodesetFileCollection collection = Create((newer, "2.0.0"), (older, "1.0.0")); Assert.That(collection.Files[ModelUri], Is.EqualTo(newer)); } /// /// Regression: Files exposes one entry per model URI, so a superseded /// version's path was not recognised as a NodeSet2 input and the /// ModelDesign pass picked it up and generated the model a second time. /// AllFilePaths reports every loaded file. /// [Test] public void AllFilePathsIncludesSupersededVersions() { const string older = "memory://older.NodeSet2.xml"; const string newer = "memory://newer.NodeSet2.xml"; m_fileSystem.Add(older, Encoding.UTF8.GetBytes(NodeSet("1.0.0"))); m_fileSystem.Add(newer, Encoding.UTF8.GetBytes(NodeSet("2.0.0"))); NodesetFileCollection collection = Create((older, "1.0.0"), (newer, "2.0.0")); Assert.That(collection.Files.Values, Is.EquivalentTo(new[] { newer })); Assert.That( collection.AllFilePaths, Is.EquivalentTo(new[] { newer, older }), "a superseded NodeSet is still a NodeSet input"); } /// /// B-7: a NodeSet2 input that is not well formed is skipped but still /// recorded as a NodeSet input, so it is not handed to the ModelDesign /// pass (whose load failure aborted every model). /// [Test] public void AllFilePathsIncludesMalformedNodeSet() { const string valid = "memory://valid.NodeSet2.xml"; const string malformed = "memory://malformed.NodeSet2.xml"; m_fileSystem.Add(valid, Encoding.UTF8.GetBytes(NodeSet("1.0.0"))); m_fileSystem.Add(malformed, Encoding.UTF8.GetBytes("\r\n" + NodeSet("2.0.0"))); NodesetFileCollection collection = Create((valid, null), (malformed, null)); Assert.That(collection.Files.Values, Is.EquivalentTo(new[] { valid })); Assert.That(collection.AllFilePaths, Is.EquivalentTo(new[] { valid, malformed })); } /// /// Regression: the NodeSet's own <Model ModelVersion="..."> was never /// read. OPC 10000-6 F.2 Table F.1 orders editions by the SemVer /// ModelVersion, so 1.5.10 is newer than 1.5.9 whatever the dates say. /// [Test] public void DeclaredModelVersionIsUsedWhenTheItemMetadataHasNone() { const string older = "memory://a-older.NodeSet2.xml"; const string newer = "memory://b-newer.NodeSet2.xml"; // The 1.5.9 file is published later, so a date comparison would // pick it; the declared ModelVersions say otherwise. m_fileSystem.Add( older, Encoding.UTF8.GetBytes(NodeSet("1.05.9", "2026-09-01T00:00:00Z", "1.5.9"))); m_fileSystem.Add( newer, Encoding.UTF8.GetBytes(NodeSet("1.05.10", "2026-01-01T00:00:00Z", "1.5.10"))); // No options.Version - the version has to come from . NodesetFileCollection collection = Create((older, null), (newer, null)); Assert.That( collection.Files[ModelUri], Is.EqualTo(newer), "1.5.10 is newer than 1.5.9 regardless of publication date"); } /// /// Regression (N4): the Version attribute was compared although OPC /// 10000-6 F.2 says it is "not intended for programmatic comparisons", /// and ModelVersion was ignored. Here the Version text says "Draft" /// on the newer model, which is newer by ModelVersion. /// [Test] public void ModelVersionWinsOverTheVersionAttribute() { const string a = "memory://a.NodeSet2.xml"; const string b = "memory://b.NodeSet2.xml"; m_fileSystem.Add( a, Encoding.UTF8.GetBytes(NodeSet("1.05.04", "2024-01-01T00:00:00Z", "1.5.4"))); m_fileSystem.Add( b, Encoding.UTF8.GetBytes(NodeSet("Draft", "2025-01-01T00:00:00Z", "1.5.5"))); Assert.That(Create((a, null), (b, null)).Files[ModelUri], Is.EqualTo(b)); } /// /// Regression (N4): without a ModelVersion the PublicationDate decides /// (OPC 10000-6 F.2); the Version attribute used to outrank it. /// [Test] public void PublicationDateDecidesWhenNoModelVersionIsDeclared() { const string a = "memory://a.NodeSet2.xml"; const string b = "memory://b.NodeSet2.xml"; m_fileSystem.Add(a, Encoding.UTF8.GetBytes(NodeSet("2.0.0", "2024-01-01T00:00:00Z"))); m_fileSystem.Add(b, Encoding.UTF8.GetBytes(NodeSet("1.0.0", "2025-01-01T00:00:00Z"))); Assert.That(Create((a, null), (b, null)).Files[ModelUri], Is.EqualTo(b)); } /// /// Regression (N4): a model that declares a ModelVersion is newer than /// one that does not, and the PublicationDate is then ignored. /// [Test] public void OnlyModelWithModelVersionWins() { const string a = "memory://a.NodeSet2.xml"; const string b = "memory://b.NodeSet2.xml"; m_fileSystem.Add(a, Encoding.UTF8.GetBytes(NodeSet("9.0.0", "2026-01-01T00:00:00Z"))); m_fileSystem.Add( b, Encoding.UTF8.GetBytes(NodeSet("1.0.0", "2020-01-01T00:00:00Z", "1.0.0"))); Assert.That(Create((a, null), (b, null)).Files[ModelUri], Is.EqualTo(b)); m_fileSystem.Dispose(); m_fileSystem = new VirtualFileSystem(); m_fileSystem.Add(a, Encoding.UTF8.GetBytes(NodeSet("9.0.0", "2026-01-01T00:00:00Z"))); m_fileSystem.Add( b, Encoding.UTF8.GetBytes(NodeSet("1.0.0", "2020-01-01T00:00:00Z", "1.0.0"))); Assert.That(Create((b, null), (a, null)).Files[ModelUri], Is.EqualTo(b)); } /// /// Item metadata still wins over the NodeSet's own declaration. /// [Test] public void ItemMetadataVersionOverridesTheDeclaredModelVersion() { const string first = "memory://first.NodeSet2.xml"; const string second = "memory://second.NodeSet2.xml"; m_fileSystem.Add(first, Encoding.UTF8.GetBytes(NodeSet("9.0.0"))); m_fileSystem.Add(second, Encoding.UTF8.GetBytes(NodeSet("1.0.0"))); // The metadata reverses what the files declare. NodesetFileCollection collection = Create( (first, "1.0.0"), (second, "9.0.0")); Assert.That(collection.Files[ModelUri], Is.EqualTo(second)); } /// /// Regression: the ISO-date guard only fired when both sides were dates, /// so a version-less NodeSet (whose version is its publication date) was /// run through the version parser against a real version. "2021-04-15" /// loses its month and day to the pre-release split and comes back as /// major 2021, which outranks every real version - the stale NodeSet won. /// [Test] public void ExplicitVersionIsNotBeatenByADateShapedVersion() { const string dated = "memory://a-dated.NodeSet2.xml"; const string versioned = "memory://b-versioned.NodeSet2.xml"; // The dated one is older by publication date, so it must lose. m_fileSystem.Add( dated, Encoding.UTF8.GetBytes(NodeSet(null!, "2021-04-15T00:00:00Z"))); m_fileSystem.Add( versioned, Encoding.UTF8.GetBytes(NodeSet("1.05.4", "2026-08-12T00:00:00Z"))); NodesetFileCollection collection = Create( (dated, null), (versioned, "1.05.4")); Assert.That( collection.Files[ModelUri], Is.EqualTo(versioned), "a year parsed as a major version must not outrank a real version"); } /// /// A version-less NodeSet falls back to its publication date, which must /// keep comparing as a date rather than going through the version parser. /// [Test] public void PublicationDateIsUsedWhenNoVersionIsDeclared() { const string older = "memory://older.NodeSet2.xml"; const string newer = "memory://newer.NodeSet2.xml"; m_fileSystem.Add( older, Encoding.UTF8.GetBytes(NodeSet(null!, "2024-05-01T00:00:00Z"))); m_fileSystem.Add( newer, Encoding.UTF8.GetBytes(NodeSet(null!, "2024-12-01T00:00:00Z"))); NodesetFileCollection collection = Create((older, null), (newer, null)); Assert.That(collection.Files[ModelUri], Is.EqualTo(newer)); } /// /// Regression: the pairwise "keep the newer one" selection used a /// comparison that was not transitive (a version against a date compared /// equal and was then settled by publication date), so which NodeSet won /// depended on the order of the AdditionalFiles. /// [TestCase(0, 1, 2)] [TestCase(0, 2, 1)] [TestCase(1, 0, 2)] [TestCase(1, 2, 0)] [TestCase(2, 0, 1)] [TestCase(2, 1, 0)] public void NewestSelectionDoesNotDependOnInputOrder(int first, int second, int third) { (string Path, string? Version, string Published)[] candidates = [ ("memory://a.NodeSet2.xml", "1.0.2", "2024-01-01T00:00:00Z"), ("memory://b.NodeSet2.xml", null, "2023-01-01T00:00:00Z"), ("memory://c.NodeSet2.xml", "1.0.3", "2022-01-01T00:00:00Z") ]; foreach ((string path, string? version, string published) in candidates) { m_fileSystem.Add( path, Encoding.UTF8.GetBytes(NodeSet(version, published, version))); } NodesetFileCollection collection = Create( (candidates[first].Path, null), (candidates[second].Path, null), (candidates[third].Path, null)); Assert.That( collection.Files[ModelUri], Is.EqualTo("memory://c.NodeSet2.xml"), "the highest declared version wins whatever the order"); } /// /// Regression: a ModelUri item metadata that differs from the model URI /// the NodeSet declares was used as the model's key for lookups while the /// collection was keyed by the declared URI, so the model was silently /// never generated. /// [Test] public void ModelUriMetadataThatDiffersFromTheNodeSetDoesNotLoseTheModel() { const string path = "memory://override.NodeSet2.xml"; m_fileSystem.Add(path, Encoding.UTF8.GetBytes(NodeSet("1.0.0"))); var collection = new NodesetFileCollection( [(path, new NodesetFileOptions { ModelUri = ModelUri + "Other/" })], [], m_fileSystem, NUnitTelemetryContext.Create(logLevel: LogLevel.Error)); Assert.That(collection.ModelUris, Is.EqualTo(new[] { ModelUri })); Assert.That( collection.GetDesignFileListForModel(ModelUri, out NodesetFile nodeset), Is.Not.Null); Assert.That(nodeset.Info!.ModelUri, Is.EqualTo(ModelUri)); } /// /// Regression: a dependency that lists the root model among its own /// namespaces added the root to the design file list a second time. /// [Test] public void MutuallyReferencingNodeSetsListTheRootOnce() { const string rootUri = "http://test.org/UA/Root/"; const string otherUri = "http://test.org/UA/Other/"; const string root = "memory://root.NodeSet2.xml"; const string other = "memory://other.NodeSet2.xml"; m_fileSystem.Add(root, Encoding.UTF8.GetBytes(NodeSetFor(rootUri, otherUri))); m_fileSystem.Add(other, Encoding.UTF8.GetBytes(NodeSetFor(otherUri, rootUri))); var collection = new NodesetFileCollection( [(root, new NodesetFileOptions()), (other, new NodesetFileOptions())], [], m_fileSystem, NUnitTelemetryContext.Create(logLevel: LogLevel.Error)); List files = collection.GetDesignFileListForModel(rootUri, out _)!; Assert.That( files!.Select(f => f.Split(',')[0]), Is.EqualTo(new[] { root, other })); } /// /// Default Name/Prefix derived from the model URI. Names that already are /// valid identifiers must not change (existing consumers depend on them); /// the others - leading digit, '.', characters an identifier cannot hold, /// an empty path - are repaired, the same way on every OS. /// [TestCase("http://opcfoundation.org/UA/DI/", "DI", "DI")] [TestCase("http://opcfoundation.org/UA/Robotics-Intent/", "Robotics_Intent", "Robotics_Intent")] [TestCase("http://test.org/UA/Versioned/", "Versioned", "Versioned")] [TestCase("http://opcfoundation.org/UA/Machinery/Result/", "MachineryResult", "MachineryResult")] [TestCase("urn:opcfoundation.org:2026-09:NodeSetImport", "NodeSetImport", "NodeSetImport")] [TestCase("http://www.OPCFoundation.org/UA/2013/01/ISA95", "_201301ISA95", "_201301ISA95")] [TestCase("http://example.com/Models/v1.2/", "Modelsv1_2", "Modelsv1._2")] [TestCase("http://example.com/", "example_com", "example.com")] [TestCase("http://test.org/UA/a*b%3Fc/", "a_b_c", "a_b_c")] [TestCase("http://test.org/UA/class/", "class_", "class_")] [TestCase("http://test.org/UA/My.class/", "My_class", "My.class_")] [TestCase("http://test.org/UA/Class/", "Class", "Class")] public void DefaultNameAndPrefixAreValidIdentifiers( string modelUri, string expectedName, string expectedPrefix) { Assert.That(NodesetFileCollection.GetDefaultNameFromUri(modelUri), Is.EqualTo(expectedName)); Assert.That(NodesetFileCollection.GetDefaultPrefixFromUri(modelUri), Is.EqualTo(expectedPrefix)); } /// /// Identifier sidecar resolution: relative paths are normalized, and the /// suffix fallback only matches on a directory boundary. /// [TestCase("/p/Models/Model.NodeSet2.xml", "./ids.csv", "/p/Models/ids.csv")] [TestCase("/p/Models/Model.NodeSet2.xml", "../Shared/ids.csv", "/p/Shared/ids.csv")] [TestCase("/p/Models/Model.NodeSet2.xml", "ids.csv", "/p/Models/ids.csv")] [TestCase("/p/Other/Model.NodeSet2.xml", "Di.NodeIds.csv", "/p/Models/Di.NodeIds.csv")] [TestCase("/p/Other/Model.NodeSet2.xml", "NodeIds.csv", null)] public void IdentifierSidecarPathIsNormalizedAndMatchedOnWholeSegments( string nodeSetPath, string identifierFile, string? expected) { string[] csvFiles = [ "/p/Models/ids.csv", "/q/Models/ids.csv", "/p/Shared/ids.csv", "/q/Shared/ids.csv", "/p/Models/Di.NodeIds.csv", "/p/Robotics/Opc.Ua.Di.NodeIds.csv" ]; Assert.That( NodesetIdentifierFileValidator.ResolvePath(nodeSetPath, identifierFile, csvFiles), Is.EqualTo(expected)); } private static string NodeSetFor(string modelUri, string otherUri) { return $""" {modelUri} {otherUri} i=45 SomeType i=58 """; } private NodesetFileCollection Create(params (string Path, string? Version)[] inputs) { ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error); List<(string, NodesetFileOptions)> files = [.. inputs.Select(i => ( i.Path, new NodesetFileOptions { Version = i.Version }))]; return new NodesetFileCollection( [.. files], [], m_fileSystem, telemetry); } private static string NodeSet( string? version, string publicationDate = "2026-08-12T00:00:00Z", string? modelVersion = null) { string versionAttribute = version == null ? string.Empty : $" Version=\"{version}\""; if (modelVersion != null) { versionAttribute += $" ModelVersion=\"{modelVersion}\""; } return $""" {ModelUri} i=45 SomeType i=58 """; } } }