/* ======================================================================== * Copyright (c) 2005-2026 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 Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using NUnit.Framework; using Opc.Ua.SourceGeneration.Dependency; namespace Opc.Ua.SourceGeneration { /// /// Verifies opt-in client extensions against a separately compiled model and the real client runtime. /// [TestFixture] [SetCulture("en-us")] [SetUICulture("en-us")] public sealed class ClientAccessorGeneratorTests { /// /// Referencing an existing model does not implicitly opt a consumer into client extension emission. /// [Test] public void ClientAccessorsAreDisabledByDefault() { (GeneratorRunResult result, Compilation output, ImmutableArray diagnostics) = RunConsumer(enabled: false); Assert.That(diagnostics, Is.Empty); Assert.That(result.GeneratedSources, Is.Empty); Assert.That(output.GetTypeByMetadataName("Example.Model.Client.ClientAccessorsClientMembers"), Is.Null); } /// /// Typed codecs and repeated-object factories compile without re-emitting the referenced model. /// [TestCase(false)] [TestCase(true)] public void ClientAccessorsCompileAgainstReferencedModels(bool customizeNames) { var options = new Dictionary(); string fullName = "Example.Model.Client.ClientAccessorsClientMembers"; if (customizeNames) { options["build_property.ModelSourceGeneratorClientAccessorNamespace"] = "Example.Views"; options["build_property.ModelSourceGeneratorClientAccessorClassName"] = "DeviceMembers"; fullName = "Example.Views.DeviceMembers"; } (GeneratorRunResult result, Compilation output, ImmutableArray diagnostics) = RunConsumer(options: options); Assert.That(diagnostics, Is.Empty, string.Join(Environment.NewLine, diagnostics)); Assert.That(result.GeneratedSources, Has.Length.EqualTo(1)); Assert.That(result.GeneratedSources[0].HintName, Does.EndWith(".ClientMembers.g.cs")); AssertCompiles(output); INamedTypeSymbol accessors = output.GetTypeByMetadataName(fullName)!; Assert.That(accessors, Is.Not.Null); IMethodSymbol[] methods = accessors.GetMembers().OfType().ToArray(); Assert.That(methods.Select(method => method.Name), Is.EquivalentTo(s_expectedMembers)); Assert.Multiple(() => { Assert.That(ReturnType("ActiveVariable"), Is.EqualTo("Opc.Ua.Client.ClientVariable")); Assert.That(ReturnType("SamplesVariable"), Is.EqualTo("Opc.Ua.Client.ClientVariable>")); Assert.That(ReturnType("ZoneVariable"), Is.EqualTo("Opc.Ua.Client.ClientVariable")); Assert.That(ReturnType("KindVariable"), Is.EqualTo("Opc.Ua.Client.ClientVariable")); Assert.That(ReturnType("AnyValueVariable"), Is.EqualTo("Opc.Ua.Client.ClientVariable")); Assert.That(ReturnType("EnumeratePartAsync"), Is.EqualTo("System.Collections.Generic.IAsyncEnumerable")); Assert.That(methods.All(method => method.IsExtensionMethod), Is.True); Assert.That(methods.All(method => method.Parameters[0].Type.ToDisplayString() == "Example.Model.AccessorDeviceTypeClient"), Is.True); }); string source = result.GeneratedSources[0].SourceText.ToString(); Assert.That(source, Does.Contain("\"http://opcfoundation.org/UA/\", \"Axis 1\", true")); string ReturnType(string name) => methods.Single(method => method.Name == name).ReturnType.ToDisplayString(); } /// /// Client-only mode must not fall back to producing another copy of an absent or differently named model. /// [TestCase(false)] [TestCase(true)] public void ClientAccessorsRequireAMatchingModelProducer(bool wrongPrefix) { (GeneratorRunResult result, _, ImmutableArray diagnostics) = RunConsumer( referenceProducer: wrongPrefix, prefix: wrongPrefix ? "Wrong.Model" : null); Assert.That(diagnostics.Any(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error && diagnostic.GetMessage(CultureInfo.InvariantCulture) .Contains("requires a referenced producer", StringComparison.Ordinal)), Is.True); Assert.That(result.GeneratedSources, Is.Empty); } /// /// Two incompatible accessor-only modes are refused explicitly. /// [TestCase("FluentAccessorsOnly")] [TestCase("GenerateNodeManager")] public void ClientAccessorsRejectIncompatibleModes(string mode) { (GeneratorRunResult result, _, ImmutableArray diagnostics) = RunConsumer(options: new() { ["build_property.ModelSourceGenerator" + mode] = "true" }); Assert.That(diagnostics.Any(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error && diagnostic.GetMessage(CultureInfo.InvariantCulture) .Contains("cannot be combined", StringComparison.Ordinal)), Is.True); Assert.That(result.GeneratedSources, Is.Empty); } /// /// ModelDesign inputs use the same referenced-model path and fully qualified runtime names. /// [Test] public void ClientAccessorsSupportModelDesignInputs() { CSharpCompilation producer = ModelDependencyScannerTests.CreateStackCompilation("DesignProducer"); (GeneratorRunResult _, Compilation model, ImmutableArray modelDiagnostics) = RunDesign(producer, false); Assert.That(modelDiagnostics, Is.Empty, string.Join(Environment.NewLine, modelDiagnostics)); AssertCompiles(model); CSharpCompilation consumer = ModelDependencyScannerTests.CreateStackCompilation("DesignConsumer") .AddReferences(model.ToMetadataReference()); consumer = consumer.AddReferences(ClientReference(consumer)); (GeneratorRunResult result, Compilation output, ImmutableArray diagnostics) = RunDesign(consumer, true); Assert.That(diagnostics, Is.Empty, string.Join(Environment.NewLine, diagnostics)); Assert.That(result.GeneratedSources, Has.Length.EqualTo(1)); Assert.That(result.GeneratedSources[0].HintName, Does.EndWith(".ClientMembers.g.cs")); AssertCompiles(output); INamedTypeSymbol accessors = output.GetTypeByMetadataName( "Acme.Opc.Ua.Pumps.Client.NestedOpcPrefixClientMembers")!; Assert.That(accessors, Is.Not.Null); Assert.That(accessors.GetMembers().OfType().Select(method => method.Name), Is.EquivalentTo(s_designMembers)); } /// /// Referencing Core carries its Programs dependency without claiming ownership of its payload or types. /// [TestCase(false)] [TestCase(true)] public void GeneratedProducerKeepsReferencedProgramsPayloadOwnedByCore(bool modelDesign) { const string programsUri = "http://opcfoundation.org/UA/SDK/Programs/"; CSharpCompilation producer; if (modelDesign) { (_, Compilation model, ImmutableArray diagnostics) = RunDesign(ModelDependencyScannerTests.CreateStackCompilation("DesignProducer"), false); Assert.That(diagnostics, Is.Empty, string.Join(Environment.NewLine, diagnostics)); AssertCompiles(model); producer = (CSharpCompilation)model; } else { producer = s_producer.Value; } CSharpCompilation probe = ModelDependencyScannerTests.CreateStackCompilation("OwnershipProbe") .AddReferences(producer.ToMetadataReference()); ModelDependencyReference[] programs = [.. ReferencedModelDependencyScanner.Scan(probe) .Where(reference => reference.ModelUri == programsUri)]; Assert.That(programs, Has.Length.EqualTo(2)); ModelDependencyReference owner = programs.Single(reference => reference.AssemblyName == "Opc.Ua.Core"); ModelDependencyReference dependency = programs.Single(reference => reference.AssemblyName == producer.AssemblyName); ModelDependencyV1? payload = owner.GetDependency(); Assert.That(payload, Is.Not.Null, "The normal reference layout must contain the current Programs model."); Assert.Multiple(() => { Assert.That(payload!.ModelUri, Is.EqualTo(programsUri)); Assert.That(payload.Nodes.Any(node => node.SymbolicName == "ManagedProgramType"), Is.True); Assert.That(dependency.Payload, Is.Empty); Assert.That(dependency.GetDependency(), Is.Null); Assert.That(dependency.Prefix, Is.EqualTo(owner.Prefix)); Assert.That(dependency.Version, Is.EqualTo(owner.Version)); Assert.That(dependency.PublicationDate, Is.EqualTo(owner.PublicationDate)); Assert.That(dependency.Name, Is.EqualTo(owner.Name)); Assert.That(producer.Assembly.GetTypeByMetadataName("Opc.Ua.Programs.ManagedProgramState"), Is.Null); Assert.That(producer.Assembly.GetTypeByMetadataName( modelDesign ? "Acme.Opc.Ua.Pumps.PumpState" : "Example.Model.AccessorDeviceState"), Is.Not.Null); }); TestContext.Out.WriteLine( $"Programs owner={owner.AssemblyName}, payloadBytes={owner.Payload.Length}; " + $"dependency={dependency.AssemblyName}, payloadBytes={dependency.Payload.Length}."); } /// /// Uses the existing nontrivial design fixture, including a namespace containing Opc. /// private static (GeneratorRunResult Result, Compilation Output, ImmutableArray Diagnostics) RunDesign(CSharpCompilation compilation, bool clientOnly) { var options = new AnalyzerOptionsProvider(new Dictionary { ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true", ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true", ["build_property.ModelSourceGeneratorClientAccessorsOnly"] = clientOnly ? "true" : "false" }); GeneratorDriver driver = CSharpGeneratorDriver.Create(new ModelSourceGenerator()) .WithUpdatedParseOptions(new CSharpParseOptions(LanguageVersion.CSharp13)) .WithUpdatedAnalyzerConfigOptions(options) .AddAdditionalTexts([EmbeddedText.From("NestedOpcPrefix.ModelDesign.xml")]); driver = driver.RunGeneratorsAndUpdateCompilation( compilation, out Compilation output, out ImmutableArray diagnostics); return (driver.GetRunResult().Results[0], output, diagnostics); } /// /// Generates a consumer using the existing compiler harness and a real client assembly reference. /// private static (GeneratorRunResult Result, Compilation Output, ImmutableArray Diagnostics) RunConsumer( bool enabled = true, bool referenceProducer = true, string? prefix = null, Dictionary? options = null) { CSharpCompilation consumer = ModelDependencyScannerTests.CreateStackCompilation("ClientAccessorConsumer"); if (referenceProducer) { consumer = consumer.AddReferences(s_producer.Value.ToMetadataReference()); } consumer = consumer.AddReferences(ClientReference(consumer)); options ??= []; if (enabled) { options["build_property.ModelSourceGeneratorClientAccessorsOnly"] = "true"; } return Run(consumer, options, prefix); } /// /// Resolves the actual client runtime built for the same configuration and target as the compiler harness. /// private static PortableExecutableReference ClientReference(Compilation compilation) { MetadataReference core = compilation.References.First(reference => Path.GetFileName(reference.Display) == "Opc.Ua.Core.dll"); var output = new DirectoryInfo(Path.GetDirectoryName(core.Display)!); string client = Path.GetFullPath(Path.Combine( output.FullName, "..", "..", "..", "..", "Opc.Ua.Client", "bin", output.Parent!.Name, output.Name, "Opc.Ua.Client.dll")); return MetadataReference.CreateFromFile(client); } /// /// Creates the separately emitted model, including its standard proxy and dependency metadata. /// private static CSharpCompilation CreateProducer() { CSharpCompilation compilation = ModelDependencyScannerTests.CreateStackCompilation("ClientAccessorProducer"); (_, Compilation output, ImmutableArray diagnostics) = Run(compilation, new() { ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true", ["build_property.ModelSourceGeneratorOmitEventRecords"] = "true" }); Assert.That(diagnostics, Is.Empty, string.Join(Environment.NewLine, diagnostics)); AssertCompiles(output); return (CSharpCompilation)output; } /// /// Runs the real incremental generator with explicit source identity. /// private static (GeneratorRunResult Result, Compilation Output, ImmutableArray Diagnostics) Run( CSharpCompilation compilation, Dictionary settings, string? prefix = null) { var options = new AnalyzerOptionsProvider(settings); options.TextOptions["ClientAccessors.NodeSet2.xml"] = new Dictionary { ["build_metadata.AdditionalFiles.ModelSourceGeneratorModelUri"] = "urn:test:client-accessors", ["build_metadata.AdditionalFiles.ModelSourceGeneratorName"] = "ClientAccessors", ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = prefix ?? "Example.Model" }; GeneratorDriver driver = CSharpGeneratorDriver.Create(new ModelSourceGenerator()) .WithUpdatedParseOptions(new CSharpParseOptions(LanguageVersion.CSharp13)) .WithUpdatedAnalyzerConfigOptions(options) .AddAdditionalTexts([EmbeddedText.Create("ClientAccessors.NodeSet2.xml", kModel)]); driver = driver.RunGeneratorsAndUpdateCompilation( compilation, out Compilation output, out ImmutableArray diagnostics); return (driver.GetRunResult().Results[0], output, diagnostics); } /// /// Checks both binding diagnostics and assembly emission rather than source-text presence alone. /// private static void AssertCompiles(Compilation compilation) { using var stream = new MemoryStream(); var result = compilation.Emit(stream); Assert.That(result.Success, Is.True, string.Join(Environment.NewLine, result.Diagnostics)); } /// The model producer shared by the independent consumer cases. private static readonly Lazy s_producer = new(CreateProducer); /// The complete direct extension surface of the synthetic model. private static readonly string[] s_expectedMembers = [ "ActiveVariable", "SamplesVariable", "ZoneVariable", "KindVariable", "AnyValueVariable", "Axis1Variable", "EnumeratePartAsync" ]; /// The direct ObjectType members of the shared ModelDesign fixture. private static readonly string[] s_designMembers = ["FlowVariable", "SettingsVariable"]; /// A synthetic model with scalar, array, enum, structure, variant and placeholder members. private const string kModel = """ urn:test:client-accessors i=58 ns=1;i=6001 ns=1;i=6002 ns=1;i=6003 ns=1;i=6004 ns=1;i=6005 ns=1;i=6006 ns=1;i=2001 ns=1;i=1001 i=63 i=78 ns=1;i=1001 i=63 i=80 ns=1;i=1001 i=63 i=80 ns=1;i=1001 i=63 i=80 ns=1;i=1001 i=63 i=80 ns=1;i=1001 i=63 i=80 ns=1;i=1001 i=58 i=11508 """; } }