Files
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00

1985 lines
92 KiB
C#

/* ========================================================================
* 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
{
/// <summary>
/// Test generation and compilation
/// </summary>
[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<string, string>().WithOpcUaGeneratedStack(), languageVersion);
var options = new AnalyzerOptionsProvider(
new Dictionary<string, string>
{
["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<string, string>().WithOpcUaGeneratedStack(), languageVersion);
var options = new AnalyzerOptionsProvider(
new Dictionary<string, string>
{
["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<string, string>
{
["build_property.ModelSourceGeneratorVersion"] = "v105",
["build_property.ModelSourceGeneratorExclude"] = "Draft",
["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true",
["build_property.ModelSourceGeneratorOmitFluentApi"] = "true",
["build_property.ModelSourceGeneratorOmitEventRecords"] = "true"
});
(ImmutableArray<Diagnostic> 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<global::Opc.Ua.ByteString> tickets"));
});
}
[Test]
public void GenerateAndCompileDemoModelWotNativeProjectionTest()
{
var generator = new ModelSourceGenerator();
CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation()
.AddCode(
new Dictionary<string, string>().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<Diagnostic> 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<string, string>
{
["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<string, string>
{
["build_metadata.AdditionalFiles.ModelSourceGeneratorWot"] = "true",
["build_metadata.AdditionalFiles.ModelSourceGeneratorIgnore"] = "true"
};
(ImmutableArray<Diagnostic> 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<Diagnostic> 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<string, string>
{
["build_metadata.AdditionalFiles.ModelSourceGeneratorIgnore"] = "true"
};
(ImmutableArray<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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");
}
/// <summary>
/// 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.
/// </summary>
[Test]
public void WotInputsDifferingOnlyInCaseAreNotReportedAsCollisionTest()
{
string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString();
string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml);
(ImmutableArray<Diagnostic> 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));
}
/// <summary>
/// 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.
/// </summary>
[TestCase("<linker><assembly fullname=\"Some.Assembly\" /></linker>")]
[TestCase("<?xml version=\"1.0\"?>\n<!-- config -->\n<configuration><appSettings /></configuration>")]
[TestCase("<ModelDesign xmlns=\"http://example.org/NotTheOpcSchema\" />")]
public void UnrelatedXmlAdditionalFileIsNotTreatedAsModelTest(string xml)
{
(ImmutableArray<Diagnostic> 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));
}
/// <summary>
/// Regression: ModelSourceGeneratorIgnore was honoured for NodeSet2
/// and WoT inputs only; an ignored ModelDesign was still generated.
/// </summary>
[Test]
public void IgnoredModelDesignInputIsNotGeneratedTest()
{
AnalyzerOptionsProvider options = DemoModelDesignOptions();
options.TextOptions["DemoModel.xml"] = new Dictionary<string, string>
{
["build_metadata.AdditionalFiles.ModelSourceGeneratorIgnore"] = "true"
};
(ImmutableArray<Diagnostic> 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<string, string>
{
["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<string, string>
{
["build_metadata.AdditionalFiles.ModelSourceGeneratorIgnore"] = "true"
};
(ImmutableArray<Diagnostic> 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<Diagnostic> 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<string, string>
{
["build_metadata.AdditionalFiles.ModelSourceGeneratorIgnore"] = "true"
};
(ImmutableArray<Diagnostic> 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<string, string>
{
["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 <Model> 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<string, string>().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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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<string, string>().WithOpcUaGeneratedStack(), languageVersion);
var options = new AnalyzerOptionsProvider(
new Dictionary<string, string>
{
["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<string>(
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<string, string>().WithOpcUaGeneratedStack(), languageVersion);
var options = new AnalyzerOptionsProvider(
new Dictionary<string, string>
{
["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("<ua:Model ModelUri=\"http://test.org/UA/Data/\" Version=\"1.0.0\""));
}
[Theory]
public void GenerateAndCompileModelDesignReferencingNodeSet2TypesTest(
LanguageVersion languageVersion)
{
// Regression test for issue #3937: a ModelDesign describing an
// object instance (CrossModelInstances.ModelDesign.xml) whose
// TypeDefinition references an ObjectType defined in a separate
// NodeSet2 AdditionalFile (CrossModelTypes.NodeSet2.xml). The
// ModelDesign generation pass must receive the NodeSet2 input as a
// dependency so the cross-model type reference resolves; otherwise
// generation fails with MODELGEN003 ("The TypeDefinition reference
// for node Widget1 is not the expected type: ObjectTypeDesign.").
var generator = new ModelSourceGenerator();
CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation()
.AddCode(new Dictionary<string, string>().WithOpcUaGeneratedStack(), languageVersion);
var options = new AnalyzerOptionsProvider(
new Dictionary<string, string>
{
// 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<string, string>
{
["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<string, string>
{
["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<string, string>().WithOpcUaGeneratedStack(), languageVersion);
var options = new AnalyzerOptionsProvider(
new Dictionary<string, string>
{
["build_property.ModelSourceGeneratorOmitFluentApi"] = "true"
});
options.TextOptions["CrossModelTypes.NodeSet2.xml"] =
new Dictionary<string, string>
{
["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<string, string>
{
["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<Diagnostic> 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<Diagnostic> 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");
}
/// <summary>
/// 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.
/// </summary>
[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<Diagnostic> 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<Diagnostic> 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"));
}
/// <summary>
/// Regression: a nested or generic [NodeManager] class silently got
/// its constructor and factory on an unrelated top-level class.
/// </summary>
[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<T> { }",
"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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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<string, string>
{
["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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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");
}
/// <summary>
/// Run the model generator over the shared cross-model NodeSet2 +
/// ModelDesign fixture plus a user-supplied source containing a
/// <c>[NodeManager]</c> attribute, and return the generator
/// diagnostics and run result. The output is intentionally not
/// strictly compiled: a matched <c>[NodeManager]</c> emits Fluent
/// node-manager code that references <c>Opc.Ua.Server</c> types not
/// present in this model-only test compilation.
/// </summary>
/// <summary>
/// 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.
/// </summary>
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<Diagnostic> Diagnostics, GeneratorDriverRunResult RunResult)
RunMixedModelGenerator(
LanguageVersion languageVersion,
string bindingSource,
MetadataReference? additionalReference = null)
{
var generator = new ModelSourceGenerator();
CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation()
.AddCode(new Dictionary<string, string>
{
["NodeManagerBinding.cs"] = bindingSource
}.WithOpcUaGeneratedStack(), languageVersion);
if (additionalReference != null)
{
compilation = compilation.AddReferences(additionalReference);
}
var options = new AnalyzerOptionsProvider(
new Dictionary<string, string>
{
["build_property.ModelSourceGeneratorOmitFluentApi"] = "true"
});
options.TextOptions["CrossModelTypes.NodeSet2.xml"] =
new Dictionary<string, string>
{
["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<string, string>
{
["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<Diagnostic> diagnostics);
return (diagnostics, driver.GetRunResult());
}
/// <summary>
/// Default per-file/global options shared by the WoT AdditionalFile
/// tests below.
/// <para>
/// Event records are omitted because these tests compile the DI NodeSet
/// only as a model dependency. Its generated alarm event records are
/// C# <c>record</c> types that derive from the standard UA alarm
/// records, which the deliberately class-based generated-stack stub in
/// <c>CompilerUtils</c> does not provide.
/// </para>
/// </summary>
private static AnalyzerOptionsProvider DefaultWotOptions()
{
return new AnalyzerOptionsProvider(
new Dictionary<string, string>
{
["build_property.ModelSourceGeneratorStartId"] = "1000",
["build_property.ModelSourceGeneratorOmitFluentApi"] = "true",
["build_property.ModelSourceGeneratorOmitEventRecords"] = "true"
});
}
/// <summary>
/// Converts a NodeSet2 XML document to a native-first WoT document.
/// Tests that exercise envelope integrity can request the explicit
/// byte-preserving <c>uav:nodeSet</c> archival mode.
/// </summary>
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());
}
/// <summary>
/// Flips one base64 character of the <c>uav:nodeSet</c> preservation
/// envelope's <c>data</c> value so the payload still decodes as valid
/// base64 but its SHA-256 digest no longer matches the recorded
/// <c>sha256</c> value.
/// </summary>
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);
}
/// <summary>
/// Rewrites the first <c>browseName</c> of the <c>uav:nodes</c> 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
/// <c>WotDiagnosticCode.NativeProjectionConflict</c> error, producing a
/// conversion result with a non-null value and an error diagnostic.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
private static GeneratorRunResult RunDemoModelGenerator(
LanguageVersion languageVersion,
AdditionalText modelText,
AnalyzerOptionsProvider options)
{
var generator = new ModelSourceGenerator();
CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation()
.AddCode(new Dictionary<string, string>().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);
}
/// <summary>
/// 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.
/// </summary>
private static (ImmutableArray<Diagnostic> Diagnostics, GeneratorDriverRunResult RunResult)
RunGeneratorLeniently(
AnalyzerOptionsProvider options,
IEnumerable<AdditionalText> additionalTexts,
LanguageVersion languageVersion = LanguageVersion.CSharp13)
{
var generator = new ModelSourceGenerator();
CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation()
.AddCode(new Dictionary<string, string>().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<Diagnostic> diagnostics);
return (diagnostics, driver.GetRunResult());
}
/// <summary>
/// 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.
/// </summary>
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<ClassDeclarationSyntax>()
.Where(c => c.Identifier.Text == "XmlSchemas")
.ToList();
Assert.That(classNodes, Has.Count.EqualTo(1));
ClassDeclarationSyntax classNode = classNodes[0];
var properrtyNodes = classNode.DescendantNodes()
.OfType<PropertyDeclarationSyntax>()
.Where(f => f.Identifier.Text == "TypesXsd")
.ToList();
Assert.That(properrtyNodes, Has.Count.EqualTo(1));
PropertyDeclarationSyntax propertyNode = properrtyNodes[0];
LiteralExpressionSyntax stringLiteral = propertyNode.DescendantNodes()
.OfType<LiteralExpressionSyntax>()
.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<Diagnostic> 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;
}
}
}