/* ======================================================================== * 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.Collections.Immutable; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Xml.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using NUnit.Framework; using Opc.Ua.Export; using Opc.Ua.Wot; namespace Opc.Ua.SourceGeneration { /// /// Test generation and compilation /// [TestFixture] [Category("SourceGeneration")] [SetCulture("en-us")] [SetUICulture("en-us")] // [Parallelizable(ParallelScope.All)] public class ModelGeneratorTests { [DatapointSource] public OptimizationLevel[] OptimizationLevels = CompilerUtils.SupportedOptimizationLevels; [DatapointSource] public LanguageVersion[] LanguageVersions = CompilerUtils.SupportedLanguageVersions; [Theory] public void GenerateAndCompileDemoModelXmlTest( LanguageVersion languageVersion, OptimizationLevel optimizationLevel) { var generator = new ModelSourceGenerator(); CSharpCompilation compilation = optimizationLevel.CreateCompilation() .AddCode(new Dictionary().WithOpcUaGeneratedStack(), languageVersion); var options = new AnalyzerOptionsProvider( new Dictionary { ["build_property.ModelSourceGeneratorVersion"] = "v105", ["build_property.ModelSourceGeneratorExclude"] = "Draft", ["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true", // The test compilation includes Core stubs via // WithOpcUaGeneratedStack() but no Server reference. // Suppress fluent-builder emission so the generated // code compiles standalone (matches model-only // production csprojs). ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true", ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true" }); // Create the driver the executes the generator GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(languageVersion)) .AddAdditionalTexts([EmbeddedText.From("DemoModel.xml")]) .WithUpdatedAnalyzerConfigOptions(options) ; GeneratorRunResult generatorResult = GenerateAndCompile(driver, compilation); Assert.That(generatorResult.GeneratedSources, Has.Length.EqualTo(9)); } [Theory] public void GenerateAndCompileDemoModelNodeSetsTest( LanguageVersion languageVersion, OptimizationLevel optimizationLevel) { var generator = new ModelSourceGenerator(); CSharpCompilation compilation = optimizationLevel.CreateCompilation() .AddCode(new Dictionary().WithOpcUaGeneratedStack(), languageVersion); var options = new AnalyzerOptionsProvider( new Dictionary { ["build_property.ModelSourceGeneratorStartId"] = "1000", // The test compilation includes Core stubs but no // Server reference. Suppress fluent-builder // emission so the generated code compiles standalone. ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true", ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true" }); // Create the driver the executes the generator GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(languageVersion)) .AddAdditionalTexts([ EmbeddedText.From("DemoModel.NodeSet2.xml"), EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") ]) .WithUpdatedAnalyzerConfigOptions(options) ; GeneratorRunResult generatorResult = GenerateAndCompile(driver, compilation); // 19 generated files: 9 per model (Constants, DataTypes, // Identifiers, ModelDependencies, NodeStates, NodeStates.ex, // NodeStates.i, TypeProxies, XmlSchemas) for DemoModel + 9 // for DI, plus 1 DI.StateMachineIds.g.cs (DI declares Part // 16 FSM subtypes for the software-update facet; DemoModel // declares none, so it gets no StateMachineIds output). Assert.That(generatorResult.GeneratedSources, Has.Length.EqualTo(19)); } [Test] public void SameDirectoryModelDesignFilesGenerateEveryModelTest() { var options = new AnalyzerOptionsProvider( new Dictionary { ["build_property.ModelSourceGeneratorVersion"] = "v105", ["build_property.ModelSourceGeneratorExclude"] = "Draft", ["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true", ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true", ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true" }); (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( options, [ EmbeddedText.From("OpcUaGdsModel.xml"), EmbeddedText.From("OpcUaGdsModel.csv"), EmbeddedText.From("OpcUaOnboardingModel.xml"), EmbeddedText.From("OpcUaOnboardingModel.csv") ]); Assert.That( diagnostics, Is.Empty, string.Join("\n", diagnostics.Select(diagnostic => diagnostic.ToString()))); GeneratorRunResult result = runResult.Results[0]; Assert.That(result.Exception, Is.Null); string generated = string.Join( "\n", result.GeneratedSources.Select(source => source.SourceText.ToString())); Assert.Multiple(() => { Assert.That( result.GeneratedSources.Any(source => source.HintName == "Opc.Ua.Gds.Identifiers.g.cs"), Is.True); Assert.That( result.GeneratedSources.Any(source => source.HintName == "Opc.Ua.Onboarding.Identifiers.g.cs"), Is.True); Assert.That( generated, Does.Contain("public const uint ApplicationRecordDataType = 1;")); Assert.That( generated, Does.Contain("public const uint DeviceRegistrar = 1344;")); Assert.That( generated, Does.Contain( "global::Opc.Ua.ArrayOf tickets")); }); } [Test] public void GenerateAndCompileDemoModelWotNativeProjectionTest() { var generator = new ModelSourceGenerator(); CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() .AddCode( new Dictionary().WithOpcUaGeneratedStack(), LanguageVersion.CSharp13); var options = DefaultWotOptions(); AdditionalText nodeSetText = EmbeddedText.From("DemoModel.NodeSet2.xml"); using var nodeSetStream = new MemoryStream( Encoding.UTF8.GetBytes(nodeSetText.GetText()!.ToString())); UANodeSet nodeSet = UANodeSet.Read(nodeSetStream)!; using WotDocument wot = WotNodeSetConverter.FromNodeSet(nodeSet); Assert.That( wot.TryGetEnvelope(out _), Is.False, "Source-generation equivalence shall be proved by uav:nodes, not the envelope."); AdditionalText wotText = EmbeddedText.Create( "DemoModel.tm.json", Encoding.UTF8.GetString(wot.Utf8Json.ToArray())); GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(LanguageVersion.CSharp13)) .AddAdditionalTexts( [ wotText, EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") ]) .WithUpdatedAnalyzerConfigOptions(options); GeneratorRunResult generatorResult = GenerateAndCompile(driver, compilation); Assert.That( generatorResult.GeneratedSources.Any(source => source.HintName.Contains("DemoModel", StringComparison.Ordinal)), Is.True); } [TestCase("DemoModel.tm.json")] [TestCase("DemoModel.td.json")] [TestCase("DemoModel.tm.jsonld")] [TestCase("DemoModel.td.jsonld")] public void RecognizesAllSupportedWotExtensionsTest(string fileName) { string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); GeneratorRunResult generatorResult = RunDemoModelGenerator( LanguageVersion.CSharp13, EmbeddedText.Create(fileName, wotJson), DefaultWotOptions()); Assert.That( generatorResult.GeneratedSources.Any(source => source.HintName.StartsWith("DemoModel.", StringComparison.Ordinal)), Is.True, $"'{fileName}' should be recognized as a WoT model input"); } [Test] public void PlainJsonLdWithoutOptInIsNotConsumedAsModelInputTest() { string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); // No opt-in metadata is set for the plain .jsonld file: it must be // treated as arbitrary JSON-LD, not as a WoT model, so the whole // run has nothing to generate (and reports no diagnostics), // even though the content is itself a perfectly valid WoT // envelope document. (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( DefaultWotOptions(), [EmbeddedText.Create("DemoModel.jsonld", wotJson)]); Assert.That(diagnostics, Is.Empty); Assert.That(runResult.Results[0].GeneratedSources, Is.Empty); } [Test] public void PlainJsonLdOptInIsConsumedAsModelInputTest() { string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); AnalyzerOptionsProvider options = DefaultWotOptions(); options.TextOptions["DemoModel.jsonld"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorWot"] = "true" }; GeneratorRunResult generatorResult = RunDemoModelGenerator( LanguageVersion.CSharp13, EmbeddedText.Create("DemoModel.jsonld", wotJson), options); Assert.That( generatorResult.GeneratedSources.Any(source => source.HintName.StartsWith("DemoModel.", StringComparison.Ordinal)), Is.True, "a .jsonld file with ModelSourceGeneratorWot=true should be recognized as a WoT model input"); } [Test] public void IgnoredOptedInPlainJsonLdProducesNoDiagnosticsOrSourcesTest() { var options = DefaultWotOptions(); options.TextOptions["Ignored.jsonld"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorWot"] = "true", ["build_metadata.AdditionalFiles.ModelSourceGeneratorIgnore"] = "true" }; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( options, [EmbeddedText.Create("Ignored.jsonld", "{ invalid json")]); Assert.That(runResult.Results[0].Exception, Is.Null); Assert.That( diagnostics, Is.Empty, "an ignored, explicitly opted-in .jsonld file must be excluded before parsing"); Assert.That( runResult.Results[0].GeneratedSources, Is.Empty, "an ignored, explicitly opted-in .jsonld file must emit no sources"); } [Test] public void NodeSetAndEnvelopeWotProduceEquivalentGeneratedOutputTest() { const LanguageVersion languageVersion = LanguageVersion.CSharp13; string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); GeneratorRunResult nodeSetResult = RunDemoModelGenerator( languageVersion, EmbeddedText.Create("DemoModel.NodeSet2.xml", nodeSetXml), DefaultWotOptions()); GeneratorRunResult wotResult = RunDemoModelGenerator( languageVersion, EmbeddedText.Create("DemoModel.tm.json", BuildDemoModelWotEnvelopeJson(nodeSetXml)), DefaultWotOptions()); string[] nodeSetHints = [ .. nodeSetResult.GeneratedSources .Select(s => s.HintName) .Where(h => h.StartsWith("DemoModel.", StringComparison.Ordinal)) .OrderBy(h => h, StringComparer.Ordinal) ]; string[] wotHints = [ .. wotResult.GeneratedSources .Select(s => s.HintName) .Where(h => h.StartsWith("DemoModel.", StringComparison.Ordinal)) .OrderBy(h => h, StringComparer.Ordinal) ]; Assert.That(nodeSetHints, Is.Not.Empty); Assert.That( wotHints, Is.EqualTo(nodeSetHints), "a WoT envelope input should generate the same set of hint names as the equivalent NodeSet2 input"); foreach (string hint in nodeSetHints) { string nodeSetSource = nodeSetResult.GeneratedSources .Single(s => s.HintName == hint).SourceText.ToString(); string wotSource = wotResult.GeneratedSources .Single(s => s.HintName == hint).SourceText.ToString(); Assert.That( wotSource, Is.EqualTo(nodeSetSource), $"generated source for '{hint}' should be identical between the NodeSet2 and WoT-envelope inputs"); } } [Test] public void MalformedWotJsonProducesDiagnosticWithoutGeneratorExceptionTest() { (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( DefaultWotOptions(), [ EmbeddedText.Create("Malformed.tm.json", "{ this is not valid json"), EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") ]); Assert.That( runResult.Results[0].Exception, Is.Null, "a malformed WoT document must not crash the generator"); Assert.That( diagnostics.Any(d => d.Id == "MODELGEN030"), Is.True, "a malformed WoT document should produce a MODELGEN030 diagnostic"); // The malformed WoT input must not abort generation for the rest // of the compilation: DI does not depend on it and should still // be generated. Assert.That(runResult.Results[0].GeneratedSources, Is.Not.Empty); } [Test] public void IgnoredMalformedWotJsonProducesNoDiagnosticsOrSourcesTest() { var options = DefaultWotOptions(); options.TextOptions["Malformed.tm.json"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorIgnore"] = "true" }; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( options, [EmbeddedText.Create("Malformed.tm.json", "{ this is not valid json")]); Assert.That(runResult.Results[0].Exception, Is.Null); Assert.That( diagnostics, Is.Empty, "an ignored WoT file must be excluded before parsing and diagnostic forwarding"); Assert.That( runResult.Results[0].GeneratedSources, Is.Empty, "an ignored WoT file must emit no sources"); } [Test] public void WotDocumentWithoutEnvelopeNativeMappingOrRecognizedTypeProducesDiagnosticTest() { const string json = /*lang=json,strict*/ """{ "title": "NotARecognizedThing" }"""; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( DefaultWotOptions(), [ EmbeddedText.Create("Unrecognized.td.json", json), EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") ]); Assert.That(runResult.Results[0].Exception, Is.Null); Assert.That( diagnostics.Any(d => d.Id == "MODELGEN031" && d.GetMessage(CultureInfo.InvariantCulture).Contains("WOT3001", StringComparison.Ordinal)), Is.True, "a document that is neither a recognized Thing Model/Description nor carries a " + "preservation envelope or native mapping should report WotDiagnosticCode.NoConvertibleContent (WOT3001)"); } [Test] public void DigestMismatchedWotEnvelopeProducesDiagnosticWithoutGeneratorExceptionTest() { string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string tamperedJson = TamperEnvelopeDigestData( BuildDemoModelWotEnvelopeJson(nodeSetXml, includeEnvelope: true)); (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( DefaultWotOptions(), [ EmbeddedText.Create("Tampered.tm.json", tamperedJson), EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") ]); Assert.That(runResult.Results[0].Exception, Is.Null); Assert.That( diagnostics.Any(d => d.Id == "MODELGEN031" && d.GetMessage(CultureInfo.InvariantCulture).Contains("WOT2006", StringComparison.Ordinal)), Is.True, "a tampered envelope digest should report WotDiagnosticCode.DigestMismatch (WOT2006)"); } [Test] public void ConversionErrorWithNonNullValueEmitsNoNodeSetEvenWhenDiagnosticSuppressedTest() { // A valid preservation envelope whose native projection carries a // conflicting browse name: the envelope still restores a (non-null) // NodeSet, but the native-consistency check reports an error. The // wrapper must exclude this errored result so no generated model is // emitted, independent of whether MODELGEN031 is later suppressed. string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string conflicted = TamperNativeProjectionBrowseName( BuildDemoModelWotEnvelopeJson(nodeSetXml, includeEnvelope: true)); WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert( EmbeddedText.Create("Conflict.tm.json", conflicted), new NodesetFileOptions(), default); Assert.That( outcome.Diagnostics.Any(d => d.Id == "MODELGEN031" && d.GetMessage(CultureInfo.InvariantCulture).Contains("WOT3000", StringComparison.Ordinal)), Is.True, "a native-projection conflict should be reported as a MODELGEN031 error " + "(WotDiagnosticCode.NativeProjectionConflict / WOT3000)"); Assert.That( outcome.NodeSetText, Is.Null, "a conversion result with an error diagnostic must not yield a NodeSet even when a " + "(partial) value was produced and the MODELGEN031 diagnostic could be suppressed"); } [Test] public void CollidingWotInputsProduceDiagnosticAndOnlyOneIsAcceptedTest() { string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( DefaultWotOptions(), [ EmbeddedText.Create("DemoModel.tm.json", wotJson), EmbeddedText.Create("DemoModel.td.json", wotJson), EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") ]); Assert.That(runResult.Results[0].Exception, Is.Null); Assert.That( diagnostics.Any(d => d.Id == "MODELGEN034"), Is.True, "two WoT inputs synthesizing the same virtual NodeSet2 path should report MODELGEN034"); Assert.That( runResult.Results[0].GeneratedSources .Count(s => s.HintName == "DemoModel.Constants.g.cs"), Is.EqualTo(1), "exactly one of the colliding inputs should be accepted, not zero or both"); } /// /// A4-6: the collision map compares the virtual paths ordinally, like /// the virtual file system that holds them. Generated hint names come /// from the model prefix, not from the input path, so inputs whose /// NodeSet2 paths differ only in case do not collide: no MODELGEN034 /// (which dropped a valid model), no MODELGEN003, and the model is /// generated once. /// [Test] public void WotInputsDifferingOnlyInCaseAreNotReportedAsCollisionTest() { string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( DefaultWotOptions(), [ EmbeddedText.Create("DemoModel.tm.json", wotJson), EmbeddedText.Create("demomodel.td.json", wotJson), EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") ]); Assert.That(runResult.Results[0].Exception, Is.Null); Assert.That(diagnostics.Where(d => d.Id == "MODELGEN003"), Is.Empty); Assert.That(diagnostics.Where(d => d.Id == "MODELGEN034"), Is.Empty); Assert.That( runResult.Results[0].GeneratedSources .Count(s => s.HintName == "DemoModel.Constants.g.cs"), Is.EqualTo(1)); } /// /// Regression: every *.xml AdditionalFile was treated as a /// ModelDesign, so an XML meant for another tool failed with /// MODELGEN003 and discarded the generated code of every model. /// [TestCase("")] [TestCase("\n\n")] [TestCase("")] public void UnrelatedXmlAdditionalFileIsNotTreatedAsModelTest(string xml) { (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( DemoModelDesignOptions(), [ EmbeddedText.From("DemoModel.xml"), EmbeddedText.Create("ILLink.Descriptors.xml", xml) ]); Assert.That( diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), Is.Empty); Assert.That(runResult.Results[0].GeneratedSources, Has.Length.EqualTo(9)); } /// /// Regression: ModelSourceGeneratorIgnore was honoured for NodeSet2 /// and WoT inputs only; an ignored ModelDesign was still generated. /// [Test] public void IgnoredModelDesignInputIsNotGeneratedTest() { AnalyzerOptionsProvider options = DemoModelDesignOptions(); options.TextOptions["DemoModel.xml"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorIgnore"] = "true" }; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently(options, [EmbeddedText.From("DemoModel.xml")]); Assert.That(diagnostics, Is.Empty); Assert.That(runResult.Results[0].GeneratedSources, Is.Empty); } private static AnalyzerOptionsProvider DemoModelDesignOptions() { return new AnalyzerOptionsProvider( new Dictionary { ["build_property.ModelSourceGeneratorVersion"] = "v105", ["build_property.ModelSourceGeneratorExclude"] = "Draft", ["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true", ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true", ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true" }); } [Test] public void IgnoredWotInputDoesNotOwnCollisionPathAgainstActiveWotTest() { string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); var options = DefaultWotOptions(); options.TextOptions["DemoModel.tm.json"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorIgnore"] = "true" }; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( options, [ EmbeddedText.Create("DemoModel.tm.json", wotJson), EmbeddedText.Create("DemoModel.td.json", wotJson), EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") ]); Assert.That(runResult.Results[0].Exception, Is.Null); Assert.That( diagnostics, Is.Empty, "an ignored WoT input must not reserve its synthesized NodeSet2 path"); Assert.That( runResult.Results[0].GeneratedSources .Count(s => s.HintName == "DemoModel.Constants.g.cs"), Is.EqualTo(1), "the active WoT input should generate normally when its colliding peer is ignored"); } [Test] public void WotInputCollidingWithExplicitNodeSet2FileProducesDiagnosticTest() { string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( DefaultWotOptions(), [ EmbeddedText.Create("DemoModel.NodeSet2.xml", nodeSetXml), EmbeddedText.Create("DemoModel.tm.json", wotJson), EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") ]); Assert.That(runResult.Results[0].Exception, Is.Null); Assert.That( diagnostics.Any(d => d.Id == "MODELGEN034"), Is.True, "a WoT input synthesizing the same path as an explicit NodeSet2 input should report MODELGEN034"); Assert.That( runResult.Results[0].GeneratedSources .Count(s => s.HintName == "DemoModel.Constants.g.cs"), Is.EqualTo(1), "the explicit NodeSet2 input should win; the WoT input should be dropped, not duplicated"); } [Test] public void IgnoredWotInputDoesNotCollideWithActiveNodeSet2Test() { string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); var options = DefaultWotOptions(); options.TextOptions["DemoModel.tm.json"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorIgnore"] = "true" }; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunGeneratorLeniently( options, [ EmbeddedText.Create("DemoModel.tm.json", wotJson), EmbeddedText.Create("DemoModel.NodeSet2.xml", nodeSetXml), EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") ]); Assert.That(runResult.Results[0].Exception, Is.Null); Assert.That( diagnostics, Is.Empty, "an ignored WoT input must not report a collision with an active NodeSet2 input"); Assert.That( runResult.Results[0].GeneratedSources .Count(s => s.HintName == "DemoModel.Constants.g.cs"), Is.EqualTo(1), "the active NodeSet2 input should generate normally when the colliding WoT input is ignored"); } [Test] public void WotInputPreservesAdditionalFilesOptionsTest() { string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); AnalyzerOptionsProvider options = DefaultWotOptions(); options.TextOptions["CustomWot.tm.json"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = "CustomWotPrefix", ["build_metadata.AdditionalFiles.ModelSourceGeneratorName"] = "CustomWotName", ["build_metadata.AdditionalFiles.ModelSourceGeneratorModelUri"] = "urn:opcfoundation.org:2024-01:DemoModel", ["build_metadata.AdditionalFiles.ModelSourceGeneratorVersion"] = "9.9.9" }; // None of the options above throw or are rejected even though // ModelUri/Version are set explicitly (matching, respectively // overriding, the values already declared inside the WoT input's // restored NodeSet2 element) — proving they are applied // to the WoT-derived NodeSet2 file exactly as they would be to a // NodeSet2/ModelDesign AdditionalFiles input. GeneratorRunResult generatorResult = RunDemoModelGenerator( LanguageVersion.CSharp13, EmbeddedText.Create("CustomWot.tm.json", wotJson), options); Assert.That( generatorResult.GeneratedSources.Any(s => s.HintName == "CustomWotPrefix.Constants.g.cs"), Is.True, "the Prefix metadata from the WoT AdditionalFiles item should be honored " + "after wrapping the WoT input as a NodeSet2 file"); Assert.That( generatorResult.GeneratedSources.Any(s => s.HintName.StartsWith("DemoModel.", StringComparison.Ordinal)), Is.False, "the default DemoModel prefix should not be used once Prefix is overridden"); } [Test] public void IncrementalRerunChangesOutputForWotContentButNotForUnrelatedFilesTest() { var generator = new ModelSourceGenerator(); CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() .AddCode(new Dictionary().WithOpcUaGeneratedStack(), LanguageVersion.CSharp13); AnalyzerOptionsProvider options = DefaultWotOptions(); string nodeSetXmlV1 = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); const string originalAttr = "BrowseName=\"1:Yellow\" ParentNodeId=\"ns=1;i=125\""; const string renamedAttr = "BrowseName=\"1:YellowRenamedForIncrementalTest\" ParentNodeId=\"ns=1;i=125\""; Assert.That(nodeSetXmlV1, Does.Contain(originalAttr)); string nodeSetXmlV2 = nodeSetXmlV1.Replace(originalAttr, renamedAttr, StringComparison.Ordinal); Assert.That(nodeSetXmlV2, Is.Not.EqualTo(nodeSetXmlV1)); AdditionalText wotV1 = EmbeddedText.Create( "DemoModel.tm.json", BuildDemoModelWotEnvelopeJson(nodeSetXmlV1)); AdditionalText wotV2 = EmbeddedText.Create( "DemoModel.tm.json", BuildDemoModelWotEnvelopeJson(nodeSetXmlV2)); AdditionalText unrelatedV1 = EmbeddedText.Create("Unrelated.csv", "NodeId,BrowseName\n"); AdditionalText unrelatedV2 = EmbeddedText.Create("Unrelated.csv", "NodeId,BrowseName\n1,Foo\n"); GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(LanguageVersion.CSharp13)) .AddAdditionalTexts([wotV1, EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml"), unrelatedV1]) .WithUpdatedAnalyzerConfigOptions(options); driver = driver.RunGeneratorsAndUpdateCompilation( compilation, out Compilation _, out ImmutableArray diagnostics1); Assert.That(diagnostics1, Is.Empty); string baseline = GetGeneratedSourcesText(driver.GetRunResult()); GeneratorDriver contentChangedDriver = driver .RemoveAdditionalTexts([wotV1]) .AddAdditionalTexts([wotV2]); contentChangedDriver = contentChangedDriver.RunGeneratorsAndUpdateCompilation( compilation, out Compilation _, out ImmutableArray diagnostics2); Assert.That(diagnostics2, Is.Empty); string afterContentChange = GetGeneratedSourcesText(contentChangedDriver.GetRunResult()); Assert.That( afterContentChange, Is.Not.EqualTo(baseline), "changing the WoT input content must change the generated output"); GeneratorDriver unrelatedChangedDriver = driver .RemoveAdditionalTexts([unrelatedV1]) .AddAdditionalTexts([unrelatedV2]); unrelatedChangedDriver = unrelatedChangedDriver.RunGeneratorsAndUpdateCompilation( compilation, out Compilation _, out ImmutableArray diagnostics3); Assert.That(diagnostics3, Is.Empty); string afterUnrelatedChange = GetGeneratedSourcesText(unrelatedChangedDriver.GetRunResult()); Assert.That( afterUnrelatedChange, Is.EqualTo(baseline), "changing an unrelated file must not change the WoT-derived generated output"); } [Theory] public void GenerateAndCompileIsa95JobControlNodeSet2Test( LanguageVersion languageVersion) { var generator = new ModelSourceGenerator(); CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() .AddCode(new Dictionary().WithOpcUaGeneratedStack(), languageVersion); var options = new AnalyzerOptionsProvider( new Dictionary { ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true", ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true" }); GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(languageVersion)) .AddAdditionalTexts([EmbeddedText.From("Isa95JobControl.NodeSet2.xml")]) .WithUpdatedAnalyzerConfigOptions(options); GeneratorRunResult generatorResult = GenerateAndCompile(driver, compilation); GeneratedSourceResult dataTypesSource = generatorResult.GeneratedSources.Single( source => source.HintName.EndsWith( ".DataTypes.g.cs", StringComparison.Ordinal)); Assert.That( dataTypesSource.SourceText.ToString(), Does.Contain("ISA95JobOrderDataType")); } [Test] public void Isa95JobControlNodeSet2FixtureIsComplete() { string nodeSet = EmbeddedText.From("Isa95JobControl.NodeSet2.xml") .GetText()! .ToString(); Assert.That(nodeSet, Does.Contain("OPC Foundation MIT License 1.00")); var document = XDocument.Parse(nodeSet); XNamespace ua = "http://opcfoundation.org/UA/2011/03/UANodeSet.xsd"; XElement root = document.Root!; XElement[] models = [.. root.Element(ua + "Models")!.Elements(ua + "Model")]; Assert.That(models, Has.Length.EqualTo(1)); Assert.That( models[0].Attribute("ModelUri")!.Value, Is.EqualTo("http://opcfoundation.org/UA/ISA95-JOBCONTROL_V2/")); Assert.That(models[0].Attribute("Version")!.Value, Is.EqualTo("2.0.0")); Assert.That( models[0].Attribute("PublicationDate")!.Value, Is.EqualTo("2024-01-31T00:00:00Z")); XElement[] nodes = [.. root.Elements().Where(element => element.Name.LocalName.StartsWith("UA", StringComparison.Ordinal))]; Assert.That(nodes, Has.Length.EqualTo(258)); Assert.That(root.Elements(ua + "UADataType").Count(), Is.EqualTo(11)); Assert.That(root.Elements(ua + "UAObjectType").Count(), Is.EqualTo(8)); Assert.That(root.Elements(ua + "UAVariable").Count(), Is.EqualTo(134)); Assert.That(root.Elements(ua + "UAObject").Count(), Is.EqualTo(91)); Assert.That(root.Elements(ua + "UAMethod").Count(), Is.EqualTo(14)); Assert.That( root.Elements(ua + "UAVariable").Any(variable => variable.Attribute("NodeId")!.Value == "ns=1;i=6088" && variable.Attribute("BrowseName")!.Value == "1:MaxDownloadableJobOrders"), Is.True); Assert.That( root.Elements(ua + "UAVariable").Any(variable => variable.Attribute("NodeId")!.Value == "ns=1;i=6029" && variable.Attribute("BrowseName")!.Value == "StaticStringNodeIdPattern"), Is.True); var nodeIds = new HashSet( nodes.Select(node => node.Attribute("NodeId")!.Value), StringComparer.Ordinal); string[] unresolvedSameNamespaceReferences = [ .. document.Descendants(ua + "Reference") .Select(reference => reference.Value.Trim()) .Where(reference => reference.StartsWith("ns=1;", StringComparison.Ordinal) && !nodeIds.Contains(reference)) .Distinct(StringComparer.Ordinal) ]; Assert.That(unresolvedSameNamespaceReferences, Is.Empty); } [Theory] public void GenerateAndCompileTestDataDesignTest( LanguageVersion languageVersion) { var generator = new ModelSourceGenerator(); CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() .AddCode(new Dictionary().WithOpcUaGeneratedStack(), languageVersion); var options = new AnalyzerOptionsProvider( new Dictionary { ["build_property.ModelSourceGeneratorVersion"] = "v105", ["build_property.ModelSourceGeneratorExclude"] = "Draft", ["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true", // The test compilation includes Core stubs but no // Server reference. Suppress fluent-builder // emission so the generated code compiles standalone. ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true", ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true" }); // Create the driver that executes the generator GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(languageVersion)) .AddAdditionalTexts( [ EmbeddedText.From("TestDataDesign.xml"), EmbeddedText.From("TestDataDesign.csv") ]) .WithUpdatedAnalyzerConfigOptions(options) ; // There will be 120 errors due to missing Opc.Ua dll reference GeneratorRunResult generatorResult = GenerateAndCompile(driver, compilation); // Adding the HeaterStatus enum (used by HeaterStatusMatrix in // MatrixValueDataType to exercise the typed-enum matrix codegen // path) caused the generator to emit one additional output file // for TestDataDesign, bringing the total from 8 to 9 (the same // per-model count that DemoModel produces above). Assert.That(generatorResult.GeneratedSources, Has.Length.EqualTo(9)); string testDataXmlSchema = ValidateXmlSchema(languageVersion, generatorResult); Assert.That(testDataXmlSchema, Does.Contain("().WithOpcUaGeneratedStack(), languageVersion); var options = new AnalyzerOptionsProvider( new Dictionary { // The test compilation includes Core stubs but no Server // reference. Suppress fluent-builder emission so the // generated code compiles standalone. ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true" }); // Per-file metadata: the NodeSet2 declares the type model; the // ModelDesign declares the instance model that references it. options.TextOptions["CrossModelTypes.NodeSet2.xml"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorModelUri"] = "http://test.org/UA/CrossModel/Types", ["build_metadata.AdditionalFiles.ModelSourceGeneratorName"] = "CrossModelTypes", ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = "CrossModelTypes" }; options.TextOptions["CrossModelInstances.ModelDesign.xml"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorModelUri"] = "http://test.org/UA/CrossModel/Instances" }; // Create the driver that executes the generator GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(languageVersion)) .AddAdditionalTexts( [ EmbeddedText.From("CrossModelTypes.NodeSet2.xml"), EmbeddedText.From("CrossModelInstances.ModelDesign.xml") ]) .WithUpdatedAnalyzerConfigOptions(options) ; // Before the fix the ModelDesign pass could not resolve the // NodeSet2-defined type and generation failed with MODELGEN003; // after the fix generation completes and the generated code compiles. GeneratorRunResult generatorResult = GenerateAndCompile(driver, compilation); // The instance output must reference the type class generated from // the NodeSet2 input. A resolved cross-model reference proves the // NodeSet2 input was wired into the ModelDesign pass as a dependency. string allSources = string.Join( "\n", generatorResult.GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That(allSources, Does.Contain("CrossModelTypes.WidgetState"), "instance output should reference the generated NodeSet2 type class"); } [Theory] public void GenerateAndCompileModelDesignReferencingNodeSet2TypesReversedInputOrderTest( LanguageVersion languageVersion) { var generator = new ModelSourceGenerator(); CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() .AddCode(new Dictionary().WithOpcUaGeneratedStack(), languageVersion); var options = new AnalyzerOptionsProvider( new Dictionary { ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true" }); options.TextOptions["CrossModelTypes.NodeSet2.xml"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorModelUri"] = "http://test.org/UA/CrossModel/Types", ["build_metadata.AdditionalFiles.ModelSourceGeneratorName"] = "CrossModelTypes", ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = "CrossModelTypes" }; options.TextOptions["CrossModelInstances.ModelDesign.xml"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorModelUri"] = "http://test.org/UA/CrossModel/Instances" }; GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(languageVersion)) // Ensure resolution is independent of AdditionalFiles order. .AddAdditionalTexts( [ EmbeddedText.From("CrossModelInstances.ModelDesign.xml"), EmbeddedText.From("CrossModelTypes.NodeSet2.xml") ]) .WithUpdatedAnalyzerConfigOptions(options) ; GeneratorRunResult generatorResult = GenerateAndCompile(driver, compilation); string allSources = string.Join( "\n", generatorResult.GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That(allSources, Does.Contain("CrossModelTypes.WidgetState"), "cross-model type resolution should not depend on file order"); } [Theory] public void NodeManagerBoundToNodeSet2TypesIsNotReportedUnmatchedWithModelDesign( LanguageVersion languageVersion) { // Regression for issue #3937 (follow-up comment): a [NodeManager] // bound to the NodeSet2 *types* model must not be reported as // unmatched (MODELGEN010) merely because the project also contains // a ModelDesign *instances* model. Binding resolution runs in two // passes; the NodeSet2 pass matches the binding, so the ModelDesign // pass must not false-positive it. Before the fix the ModelDesign // pass reported the binding it never saw as unmatched, which — with // TreatWarningsAsErrors — blocked the build. const string bindingSource = """ namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } } } namespace CrossModelConsumer { [global::Opc.Ua.Server.Fluent.NodeManager(NamespaceUri = "http://test.org/UA/CrossModel/Types")] public partial class TypesNodeManager { } } """; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunMixedModelGenerator(languageVersion, bindingSource); Assert.That( diagnostics.Where(d => d.Id == "MODELGEN010"), Is.Empty, "a [NodeManager] matched by the NodeSet2 pass must not be " + "reported as unmatched by the ModelDesign pass"); string generated = string.Join( "\n", runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That( generated, Does.Contain("class TypesNodeManager"), "the matched [NodeManager] should generate a node manager partial"); } [Theory] public void NodeManagerWithAdditionalNamespaceUrisReportsThemAtConstruction( LanguageVersion languageVersion) { // Issue #4329 companion: [NodeManager] can declare additional // namespace URIs (e.g. a separate instance namespace) that the // generated constructor passes to the base manager and the // generated factory advertises, so the master node manager // routes them to this manager from construction. SetNamespaces // after the fact is too late for namespace routing. const string bindingSource = """ namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } public string[] AdditionalNamespaceUris { get; set; } } } namespace CrossModelConsumer { [global::Opc.Ua.Server.Fluent.NodeManager( NamespaceUri = "http://test.org/UA/CrossModel/Types", AdditionalNamespaceUris = new[] { "http://test.org/UA/CrossModel/Types/Instance" })] public partial class TypesNodeManager { } } """; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunMixedModelGenerator(languageVersion, bindingSource); Assert.That( diagnostics.Where(d => d.Id == "MODELGEN010"), Is.Empty, "the binding with additional namespace URIs must still match"); string generated = string.Join( "\n", runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That( generated, Does.Match( @"DefaultNamespaceUris\(\)[\s\S]{0,200}" + @"""http://test\.org/UA/CrossModel/Types/Instance"""), "the manager's namespace set must include the additional namespace URI"); Assert.That( generated, Does.Match( @": base\([\s\S]{0,300}namespaceUris \?\? DefaultNamespaceUris\(\)\)"), "the constructor must report that set to the base manager"); Assert.That( generated, Does.Match( @"NamespacesUris[\s\S]{0,200}""http://test\.org/UA/CrossModel/Types/Instance"""), "the generated factory must advertise the additional namespace URI"); } /// /// A manager that needs collaborators beyond a server and a /// configuration suppresses the two-argument constructor, so that /// no caller can build it half-initialized, and chains to the /// protected one from a constructor of its own. /// [Theory] public void NodeManagerWithoutDefaultConstructorOmitsTheTwoArgumentForm( LanguageVersion languageVersion) { const string bindingSource = """ namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } public bool GenerateDefaultConstructor { get; set; } public string[] AdditionalNamespaceUris { get; set; } } } namespace CrossModelConsumer { [global::Opc.Ua.Server.Fluent.NodeManager( NamespaceUri = "http://test.org/UA/CrossModel/Types", GenerateFactory = false, GenerateDefaultConstructor = false)] public partial class TypesNodeManager { } } """; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunMixedModelGenerator(languageVersion, bindingSource); Assert.That( diagnostics.Where(d => d.Id == "MODELGEN010"), Is.Empty, "the binding must still match"); string generated = string.Join( "\n", runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That( generated, Does.Not.Contain(": this(server, configuration, null)"), "the two-argument constructor must be suppressed"); Assert.That( generated, Does.Match(@"protected\s+TypesNodeManager\("), "the namespace-set constructor must remain, as the one to chain to"); Assert.That( generated, Does.Not.Match(@"class\s+TypesNodeManagerFactory"), "GenerateFactory=false must still suppress the factory"); } [Theory] public void NodeManagerWithUnresolvedNamespaceUriReportsExpression( LanguageVersion languageVersion) { const string bindingSource = """ namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } public string[] AdditionalNamespaceUris { get; set; } } } namespace CrossModelConsumer { [global::Opc.Ua.Server.Fluent.NodeManager( NamespaceUri = GeneratedModel.NamespaceUri)] public partial class TypesNodeManager { } } """; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunMixedModelGenerator(languageVersion, bindingSource); Diagnostic[] unresolved = [.. diagnostics.Where(d => d.Id == "MODELGEN035")]; Assert.That(unresolved, Has.Length.EqualTo(1)); Assert.That( GetDiagnosticSourceText(unresolved[0], bindingSource), Is.EqualTo("GeneratedModel.NamespaceUri")); Assert.That( unresolved[0].GetMessage(CultureInfo.InvariantCulture), Does.Contain("values generated in the same compilation are unavailable")); Assert.That(diagnostics.Where(d => d.Id == "MODELGEN010"), Is.Empty); string generated = string.Join( "\n", runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That(generated, Does.Not.Contain("class TypesNodeManager")); } /// /// Regression: a nested or generic [NodeManager] class silently got /// its constructor and factory on an unrelated top-level class. /// [TestCase( "public static partial class Outer { [global::Opc.Ua.Server.Fluent.NodeManager(" + "NamespaceUri = \"http://test.org/UA/CrossModel/Types\")] " + "public partial class TypesNodeManager { } }", "nested in 'CrossModelConsumer.Outer'", TestName = "NestedNodeManagerReportsUnsupportedTarget")] [TestCase( "[global::Opc.Ua.Server.Fluent.NodeManager(" + "NamespaceUri = \"http://test.org/UA/CrossModel/Types\")] " + "public partial class TypesNodeManager { }", "generic", TestName = "GenericNodeManagerReportsUnsupportedTarget")] public void UnsupportedNodeManagerTargetReportsDiagnostic(string declaration, string reason) { string bindingSource = $$""" namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } public string[] AdditionalNamespaceUris { get; set; } } } namespace CrossModelConsumer { {{declaration}} } """; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunMixedModelGenerator(LanguageVersion.CSharp13, bindingSource); Diagnostic[] unsupported = [.. diagnostics.Where(d => d.Id == "MODELGEN036")]; Assert.That(unsupported, Has.Length.EqualTo(1)); Assert.That( unsupported[0].GetMessage(CultureInfo.InvariantCulture), Does.Contain(reason)); Assert.That( GetDiagnosticSourceText(unsupported[0], bindingSource), Is.EqualTo("TypesNodeManager")); string generated = string.Join( "\n", runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That(generated, Does.Not.Contain("class TypesNodeManager")); } [Theory] public void NodeManagerWithUnresolvedAdditionalNamespaceUrisReportsEachExpression( LanguageVersion languageVersion) { const string bindingSource = """ namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } public string[] AdditionalNamespaceUris { get; set; } } } namespace CrossModelConsumer { [global::Opc.Ua.Server.Fluent.NodeManager( NamespaceUri = "http://test.org/UA/CrossModel/Types", AdditionalNamespaceUris = new[] { GeneratedModel.FirstNamespaceUri, "http://test.org/UA/CrossModel/Types/Valid", GeneratedModel.SecondNamespaceUri })] public partial class TypesNodeManager { } } """; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunMixedModelGenerator(languageVersion, bindingSource); string[] expressions = [.. diagnostics .Where(d => d.Id == "MODELGEN035") .Select(d => GetDiagnosticSourceText(d, bindingSource)) .OrderBy(expression => expression, StringComparer.Ordinal)]; string[] expectedExpressions = { "GeneratedModel.FirstNamespaceUri", "GeneratedModel.SecondNamespaceUri" }; Assert.That(expressions, Is.EqualTo(expectedExpressions)); Assert.That(diagnostics.Where(d => d.Id == "MODELGEN010"), Is.Empty); string generated = string.Join( "\n", runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That(generated, Does.Not.Contain("class TypesNodeManager")); } [Theory] public void NodeManagerWithSourceConstantsGeneratesBinding( LanguageVersion languageVersion) { const string bindingSource = """ namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } public string[] AdditionalNamespaceUris { get; set; } } } namespace CrossModelConsumer { public static class ModelUris { public const string Types = "http://test.org/UA/CrossModel/Types"; public const string Instances = "http://test.org/UA/CrossModel/Types/Instance"; } [global::Opc.Ua.Server.Fluent.NodeManager( NamespaceUri = ModelUris.Types, AdditionalNamespaceUris = new[] { ModelUris.Instances })] public partial class TypesNodeManager { } } """; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunMixedModelGenerator(languageVersion, bindingSource); Assert.That(diagnostics.Where(d => d.Id is "MODELGEN010" or "MODELGEN035"), Is.Empty); string generated = string.Join( "\n", runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That(generated, Does.Contain("class TypesNodeManager")); Assert.That( generated, Does.Contain("\"http://test.org/UA/CrossModel/Types/Instance\"")); } [Theory] public void NodeManagerWithReferencedConstantsGeneratesBinding( LanguageVersion languageVersion) { CSharpCompilation constantsCompilation = OptimizationLevel.Release .CreateCompilation("NodeManagerConstants") .AddCode( new Dictionary { ["ModelUris.cs"] = """ namespace Shared; public static class ModelUris { public const string Types = "http://test.org/UA/CrossModel/Types"; public const string Instances = "http://test.org/UA/CrossModel/Types/Referenced"; } """ }, languageVersion); MetadataReference constantsReference = constantsCompilation.ToMetadataReference(); const string bindingSource = """ namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } public string[] AdditionalNamespaceUris { get; set; } } } namespace CrossModelConsumer { [global::Opc.Ua.Server.Fluent.NodeManager( NamespaceUri = global::Shared.ModelUris.Types, AdditionalNamespaceUris = new[] { global::Shared.ModelUris.Instances })] public partial class TypesNodeManager { } } """; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunMixedModelGenerator(languageVersion, bindingSource, constantsReference); Assert.That(diagnostics.Where(d => d.Id is "MODELGEN010" or "MODELGEN035"), Is.Empty); string generated = string.Join( "\n", runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That(generated, Does.Contain("class TypesNodeManager")); Assert.That( generated, Does.Contain("\"http://test.org/UA/CrossModel/Types/Referenced\"")); } [TestCase("null")] [TestCase("new string[] { }")] [TestCase("new string[] { null, \"\" }")] public void NodeManagerWithEmptyAdditionalNamespaceUrisGeneratesBinding( string additionalNamespaceUris) { string bindingSource = $$""" namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } public string[] AdditionalNamespaceUris { get; set; } } } namespace CrossModelConsumer { [global::Opc.Ua.Server.Fluent.NodeManager( NamespaceUri = "http://test.org/UA/CrossModel/Types", AdditionalNamespaceUris = {{additionalNamespaceUris}})] public partial class TypesNodeManager { } } """; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunMixedModelGenerator(LanguageVersion.CSharp13, bindingSource); Assert.That(diagnostics.Where(d => d.Id is "MODELGEN010" or "MODELGEN035"), Is.Empty); string generated = string.Join( "\n", runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That(generated, Does.Contain("class TypesNodeManager")); } [TestCase("null")] [TestCase("\"\"")] public void NodeManagerWithEmptyNamespaceUriRetainsSelectorlessBehavior( string namespaceUri) { string bindingSource = $$""" namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } public string[] AdditionalNamespaceUris { get; set; } } } namespace CrossModelConsumer { [global::Opc.Ua.Server.Fluent.NodeManager(NamespaceUri = {{namespaceUri}})] public partial class AmbiguousNodeManager { } } """; (ImmutableArray diagnostics, _) = RunMixedModelGenerator(LanguageVersion.CSharp13, bindingSource); Assert.That(diagnostics.Where(d => d.Id == "MODELGEN035"), Is.Empty); Assert.That(diagnostics.Where(d => d.Id == "MODELGEN010"), Has.Exactly(1).Items); } [Theory] public void NodeManagerBoundToModelDesignInstancesIsNotReportedUnmatchedWithNodeSet2( LanguageVersion languageVersion) { // Symmetric case: a [NodeManager] bound to the ModelDesign // *instances* model must not be reported unmatched by the NodeSet2 // pass. The ModelDesign pass matches it; aggregate reporting keeps // it silent. const string bindingSource = """ namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } } } namespace CrossModelConsumer { [global::Opc.Ua.Server.Fluent.NodeManager(NamespaceUri = "http://test.org/UA/CrossModel/Instances")] public partial class InstancesNodeManager { } } """; (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = RunMixedModelGenerator(languageVersion, bindingSource); Assert.That( diagnostics.Where(d => d.Id == "MODELGEN010"), Is.Empty, "a [NodeManager] matched by the ModelDesign pass must not be " + "reported as unmatched by the NodeSet2 pass"); string generated = string.Join( "\n", runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); Assert.That( generated, Does.Contain("class InstancesNodeManager"), "the matched [NodeManager] should generate a node manager partial"); } [Theory] public void SelectorlessNodeManagerAcrossTwoModelsReportsSingleAmbiguity( LanguageVersion languageVersion) { // A [NodeManager] with no NamespaceUri/Design selector cannot bind // when the project has multiple models. It must be reported exactly // once (aggregated across both passes, not once per pass) with an // ambiguity message that guides the user to add a NamespaceUri. const string bindingSource = """ namespace Opc.Ua.Server.Fluent { public sealed class NodeManagerAttribute : global::System.Attribute { public string NamespaceUri { get; set; } public string Design { get; set; } public bool GenerateFactory { get; set; } } } namespace CrossModelConsumer { [global::Opc.Ua.Server.Fluent.NodeManager] public partial class AmbiguousNodeManager { } } """; (ImmutableArray diagnostics, _) = RunMixedModelGenerator(languageVersion, bindingSource); Diagnostic[] unmatched = [.. diagnostics.Where(d => d.Id == "MODELGEN010")]; Assert.That( unmatched, Has.Length.EqualTo(1), "a selector-less binding must be reported once, not once per pass"); Assert.That( unmatched[0].GetMessage(CultureInfo.InvariantCulture), Does.Contain("multiple models"), "the diagnostic should explain the ambiguity"); } /// /// Run the model generator over the shared cross-model NodeSet2 + /// ModelDesign fixture plus a user-supplied source containing a /// [NodeManager] attribute, and return the generator /// diagnostics and run result. The output is intentionally not /// strictly compiled: a matched [NodeManager] emits Fluent /// node-manager code that references Opc.Ua.Server types not /// present in this model-only test compilation. /// /// /// The source text a diagnostic points at. Generator diagnostics /// carry a file location (path and spans, no syntax tree) so the /// incremental cache does not hold on to syntax trees; the text is /// therefore looked up in the source the location refers to. /// private static string GetDiagnosticSourceText(Diagnostic diagnostic, string source) { FileLinePositionSpan lineSpan = diagnostic.Location.GetLineSpan(); Assert.That(lineSpan.IsValid, Is.True, "the diagnostic must have a location"); var text = Microsoft.CodeAnalysis.Text.SourceText.From(source); return text.ToString(text.Lines.GetTextSpan(lineSpan.Span)); } private static (ImmutableArray Diagnostics, GeneratorDriverRunResult RunResult) RunMixedModelGenerator( LanguageVersion languageVersion, string bindingSource, MetadataReference? additionalReference = null) { var generator = new ModelSourceGenerator(); CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() .AddCode(new Dictionary { ["NodeManagerBinding.cs"] = bindingSource }.WithOpcUaGeneratedStack(), languageVersion); if (additionalReference != null) { compilation = compilation.AddReferences(additionalReference); } var options = new AnalyzerOptionsProvider( new Dictionary { ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true" }); options.TextOptions["CrossModelTypes.NodeSet2.xml"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorModelUri"] = "http://test.org/UA/CrossModel/Types", ["build_metadata.AdditionalFiles.ModelSourceGeneratorName"] = "CrossModelTypes", ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = "CrossModelTypes" }; options.TextOptions["CrossModelInstances.ModelDesign.xml"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorModelUri"] = "http://test.org/UA/CrossModel/Instances" }; GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(languageVersion)) .AddAdditionalTexts( [ EmbeddedText.From("CrossModelTypes.NodeSet2.xml"), EmbeddedText.From("CrossModelInstances.ModelDesign.xml") ]) .WithUpdatedAnalyzerConfigOptions(options); driver = driver.RunGeneratorsAndUpdateCompilation( compilation, out Compilation _, out ImmutableArray diagnostics); return (diagnostics, driver.GetRunResult()); } /// /// Default per-file/global options shared by the WoT AdditionalFile /// tests below. /// /// Event records are omitted because these tests compile the DI NodeSet /// only as a model dependency. Its generated alarm event records are /// C# record types that derive from the standard UA alarm /// records, which the deliberately class-based generated-stack stub in /// CompilerUtils does not provide. /// /// private static AnalyzerOptionsProvider DefaultWotOptions() { return new AnalyzerOptionsProvider( new Dictionary { ["build_property.ModelSourceGeneratorStartId"] = "1000", ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true", ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true" }); } /// /// Converts a NodeSet2 XML document to a native-first WoT document. /// Tests that exercise envelope integrity can request the explicit /// byte-preserving uav:nodeSet archival mode. /// private static string BuildDemoModelWotEnvelopeJson( string nodeSetXml, string? title = null, bool includeEnvelope = false) { using var nodeSetStream = new MemoryStream(Encoding.UTF8.GetBytes(nodeSetXml)); UANodeSet nodeSet = UANodeSet.Read(nodeSetStream)!; using WotDocument wot = WotNodeSetConverter.FromNodeSet( nodeSet, title, new WotNodeSetConverterOptions { PreservationMode = includeEnvelope ? WotNodeSetPreservationMode.Always : WotNodeSetPreservationMode.Never }); return Encoding.UTF8.GetString(wot.Utf8Json.ToArray()); } /// /// Flips one base64 character of the uav:nodeSet preservation /// envelope's data value so the payload still decodes as valid /// base64 but its SHA-256 digest no longer matches the recorded /// sha256 value. /// private static string TamperEnvelopeDigestData(string envelopeJson) { const string marker = "\"data\": \""; int index = envelopeJson.IndexOf(marker, StringComparison.Ordinal) + marker.Length; char[] characters = envelopeJson.ToCharArray(); characters[index] = characters[index] == 'A' ? 'B' : 'A'; return new string(characters); } /// /// Rewrites the first browseName of the uav:nodes native /// projection so it conflicts with the browse name carried by the /// preservation envelope. The envelope still restores a valid NodeSet, but /// the native-consistency check reports a /// WotDiagnosticCode.NativeProjectionConflict error, producing a /// conversion result with a non-null value and an error diagnostic. /// private static string TamperNativeProjectionBrowseName(string envelopeJson) { int nodesStart = envelopeJson.IndexOf("\"uav:nodes\"", StringComparison.Ordinal); const string marker = "\"browseName\": \""; int start = envelopeJson.IndexOf(marker, nodesStart, StringComparison.Ordinal); int valueStart = start + marker.Length; int valueEnd = envelopeJson.IndexOf('"', valueStart); return new StringBuilder(envelopeJson.Length + 9) .Append(envelopeJson, 0, valueEnd) .Append("_MISMATCH") .Append(envelopeJson, valueEnd, envelopeJson.Length - valueEnd) .ToString(); } /// /// Runs the generator for a single model AdditionalText (plus its /// dependency on the DI companion spec) and asserts a clean, /// compiling run — for tests where the WoT input is expected to /// succeed outright. /// private static GeneratorRunResult RunDemoModelGenerator( LanguageVersion languageVersion, AdditionalText modelText, AnalyzerOptionsProvider options) { var generator = new ModelSourceGenerator(); CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() .AddCode(new Dictionary().WithOpcUaGeneratedStack(), languageVersion); GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(languageVersion)) .AddAdditionalTexts([modelText, EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml")]) .WithUpdatedAnalyzerConfigOptions(options); return GenerateAndCompile(driver, compilation); } /// /// Runs the generator over the given AdditionalFiles without /// asserting success — for tests that need to inspect diagnostics /// produced by a malformed, unsupported or colliding WoT input. /// private static (ImmutableArray Diagnostics, GeneratorDriverRunResult RunResult) RunGeneratorLeniently( AnalyzerOptionsProvider options, IEnumerable additionalTexts, LanguageVersion languageVersion = LanguageVersion.CSharp13) { var generator = new ModelSourceGenerator(); CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() .AddCode(new Dictionary().WithOpcUaGeneratedStack(), languageVersion); GeneratorDriver driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(new CSharpParseOptions() .WithKind(SourceCodeKind.Regular) .WithLanguageVersion(languageVersion)) .AddAdditionalTexts([.. additionalTexts]) .WithUpdatedAnalyzerConfigOptions(options); driver = driver.RunGeneratorsAndUpdateCompilation( compilation, out Compilation _, out ImmutableArray diagnostics); return (diagnostics, driver.GetRunResult()); } /// /// Concatenates every generated source (ordered deterministically by /// hint name) into a single comparable string, used by the /// incremental re-run test to detect whether generated output /// changed between runs. /// private static string GetGeneratedSourcesText(GeneratorDriverRunResult runResult) { return string.Join( "\n----\n", runResult.Results[0].GeneratedSources .OrderBy(s => s.HintName, StringComparer.Ordinal) .Select(s => s.HintName + ":\n" + s.SourceText)); } private static string ValidateXmlSchema( LanguageVersion languageVersion, GeneratorRunResult generatorResult) { // Get the XmlSchemas.g.cs generated source which is what we care about var xmlSchemaSource = generatorResult.GeneratedSources .Where(s => s.HintName.EndsWith("XmlSchemas.g.cs", StringComparison.Ordinal)) .ToList(); Assert.That(xmlSchemaSource, Has.Count.EqualTo(1)); // Parse the generated source and verify it contains expected schema SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(xmlSchemaSource[0].SourceText); SyntaxNode root = syntaxTree.GetRoot(); var classNodes = root.DescendantNodes() .OfType() .Where(c => c.Identifier.Text == "XmlSchemas") .ToList(); Assert.That(classNodes, Has.Count.EqualTo(1)); ClassDeclarationSyntax classNode = classNodes[0]; var properrtyNodes = classNode.DescendantNodes() .OfType() .Where(f => f.Identifier.Text == "TypesXsd") .ToList(); Assert.That(properrtyNodes, Has.Count.EqualTo(1)); PropertyDeclarationSyntax propertyNode = properrtyNodes[0]; LiteralExpressionSyntax stringLiteral = propertyNode.DescendantNodes() .OfType() .FirstOrDefault()!; Assert.That(stringLiteral, Is.Not.Null); // Verify that the getter contains the expected schema string var stringTokens = stringLiteral.ChildTokens().ToList(); Assert.That(stringTokens, Has.Count.EqualTo(1)); string stringLiteralText = stringTokens[0].ToFullString().Trim(); if (stringLiteralText.EndsWith("u8", StringComparison.Ordinal)) { Assert.That(languageVersion, Is.GreaterThanOrEqualTo(LanguageVersion.CSharp11)); #pragma warning disable IDE0057 // No range available stringLiteralText = stringLiteralText.Substring(0, stringLiteralText.Length - 2); #pragma warning restore IDE0057 // No range available } stringLiteralText = stringLiteralText.Trim('"'); if (languageVersion < LanguageVersion.CSharp11) { // Base 64 encoded string is split into multiple lines return Encoding.UTF8.GetString( Convert.FromBase64String(stringLiteralText)); } return stringLiteralText; } private static GeneratorRunResult GenerateAndCompile( GeneratorDriver driver, CSharpCompilation compilation) { // Run it driver = driver.RunGeneratorsAndUpdateCompilation( compilation, out Compilation outputCompilation, out ImmutableArray diagnostics); Assert.That(diagnostics, Is.Empty); Assert.That(outputCompilation.SyntaxTrees.Count(), Is.GreaterThan(1)); outputCompilation.GetDiagnostics().Check( TestContext.Out, out int errors, out int warnings); Assert.That(errors, Is.Zero, $"Compilation produced {errors} errors"); #if NETFRAMEWORK TestContext.Out.WriteLine($"Compilation produced {warnings} warnings"); #else Assert.That(warnings, Is.Zero, $"Compilation produced {warnings} warnings"); #endif // Get the results GeneratorDriverRunResult runResult = driver.GetRunResult(); // Test the results Assert.That(runResult.GeneratedTrees, Is.Not.Empty); runResult.Diagnostics.Check(TestContext.Out, out errors, out warnings); Assert.That(errors, Is.Zero); TestContext.Out.WriteLine($"Run result produced {warnings} warnings"); GeneratorRunResult generatorResult = runResult.Results[0]; generatorResult.Diagnostics.Check(TestContext.Out, out errors, out warnings); Assert.That(errors, Is.Zero); TestContext.Out.WriteLine($"Generate run produced {warnings} warnings"); Assert.That(generatorResult.Exception, Is.Null); return generatorResult; } } }