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