Files
UA-.NETStandard/tests/Opc.Ua.SourceGeneration.Tests/WotNodeSetConversionFailureTests.cs
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

378 lines
15 KiB
C#

/* ========================================================================
* 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.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using NUnit.Framework;
using Opc.Ua.Export;
using Opc.Ua.Wot;
namespace Opc.Ua.SourceGeneration
{
/// <summary>
/// Verifies the failure and diagnostic-projection paths of the WoT
/// AdditionalFile conversion wrapper. The wrapper is documented never to
/// throw (other than on cancellation), so every one of these inputs must
/// come back as a <see cref="WotConversionOutcome"/> carrying a diagnostic
/// and no NodeSet.
/// </summary>
[TestFixture]
[Category("SourceGeneration")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public class WotNodeSetConversionFailureTests
{
[Test]
public void ConvertUsesSynchronousConverterEntryPointForSelfContainedDocument()
{
const string json = """
{
"@context": [
"https://www.w3.org/2022/wot/td/v1.1",
{
"uav": "http://opcfoundation.org/UA/WoT-Binding/"
}
],
"@type": [
"tm:ThingModel",
"uav:objectType"
],
"title": "SelfContained",
"uav:browseName": "nsu=urn:self-contained;SelfContained",
"uav:id": "nsu=urn:self-contained;s=SelfContained"
}
""";
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
EmbeddedText.Create("SelfContained.tm.json", json),
new NodesetFileOptions(),
CancellationToken.None);
using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(json));
WotConversionResult<UANodeSet> conversion = WotNodeSetConverter.ToNodeSetResult(document);
string expectedXml = WriteNodeSet(conversion.Value!);
string actualXml = outcome.NodeSetText.GetText(CancellationToken.None)!.ToString();
Assert.That(outcome.Diagnostics, Is.Empty);
Assert.That(actualXml, Is.EqualTo(expectedXml));
}
[Test]
public void ConvertReportsParseErrorWhenSourceTextThrows()
{
var source = new ThrowingAdditionalText(
"Broken.tm.json",
new InvalidOperationException("the file could not be opened"));
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
source,
new NodesetFileOptions(),
CancellationToken.None);
Assert.That(outcome.NodeSetText, Is.Null);
Assert.That(outcome.SourcePath, Is.EqualTo("Broken.tm.json"));
Diagnostic diagnostic = SingleDiagnostic(outcome, "MODELGEN030");
Assert.That(
diagnostic.GetMessage(CultureInfo.InvariantCulture),
Does.Contain("the file could not be opened"));
}
[Test]
public void ConvertPropagatesCancellationFromSourceText()
{
var source = new ThrowingAdditionalText(
"Cancelled.tm.json",
new OperationCanceledException());
Assert.That(
() => WotNodeSetAdditionalText.Convert(
source,
new NodesetFileOptions(),
CancellationToken.None),
Throws.InstanceOf<OperationCanceledException>());
}
[Test]
public void ConvertReportsParseErrorWhenSourceTextIsNull()
{
var source = new NullTextAdditionalText("Empty.tm.json");
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
source,
new NodesetFileOptions(),
CancellationToken.None);
Assert.That(outcome.NodeSetText, Is.Null);
Diagnostic diagnostic = SingleDiagnostic(outcome, "MODELGEN030");
Assert.That(
diagnostic.GetMessage(CultureInfo.InvariantCulture),
Does.Contain("could not be read"));
}
[Test]
public void ConvertReportsParseErrorForMalformedJson()
{
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
EmbeddedText.Create("Malformed.tm.json", "{ \"title\": "),
new NodesetFileOptions(),
CancellationToken.None);
Assert.That(outcome.NodeSetText, Is.Null);
Diagnostic diagnostic = SingleDiagnostic(outcome, "MODELGEN030");
Assert.That(diagnostic.Location, Is.Not.EqualTo(Location.None));
}
[Test]
public void ConvertAnchorsMalformedJsonDiagnosticAtTheOffendingLine()
{
// The syntax error sits on the third line, so the reported location
// must not collapse to the start of the file.
const string json = "{\r\n \"title\": \"Demo\",\r\n \"base\": ,\r\n}";
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
EmbeddedText.Create("BadLine.tm.json", json),
new NodesetFileOptions(),
CancellationToken.None);
Diagnostic diagnostic = SingleDiagnostic(outcome, "MODELGEN030");
FileLinePositionSpan span = diagnostic.Location.GetLineSpan();
Assert.That(span.Path, Is.EqualTo("BadLine.tm.json"));
Assert.That(span.StartLinePosition.Line, Is.GreaterThan(0));
}
[Test]
public void ConvertMapsUtf8ByteOffsetAfterAccentedCharacterToSourceTextColumn()
{
const string prefix = "{ \"title\": \"Café\", \"base\": ";
const string json = prefix + "}";
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
EmbeddedText.Create("BadAccent.tm.json", json),
new NodesetFileOptions(),
CancellationToken.None);
Diagnostic diagnostic = SingleDiagnostic(outcome, "MODELGEN030");
FileLinePositionSpan span = diagnostic.Location.GetLineSpan();
Assert.That(span.Path, Is.EqualTo("BadAccent.tm.json"));
Assert.That(span.StartLinePosition.Line, Is.Zero);
Assert.That(span.StartLinePosition.Character, Is.EqualTo(prefix.Length));
}
[Test]
public void ConvertMapsUtf8ByteOffsetAfterEmojiToSourceTextColumn()
{
const string prefix = "{ \"title\": \"Boiler 🔥\", \"base\": ";
const string json = prefix + "}";
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
EmbeddedText.Create("BadEmoji.tm.json", json),
new NodesetFileOptions(),
CancellationToken.None);
Diagnostic diagnostic = SingleDiagnostic(outcome, "MODELGEN030");
FileLinePositionSpan span = diagnostic.Location.GetLineSpan();
Assert.That(span.Path, Is.EqualTo("BadEmoji.tm.json"));
Assert.That(span.StartLinePosition.Line, Is.Zero);
Assert.That(span.StartLinePosition.Character, Is.EqualTo(prefix.Length));
}
[Test]
public void ConvertReportsParseErrorForEmptyDocument()
{
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
EmbeddedText.Create("Empty.tm.json", string.Empty),
new NodesetFileOptions(),
CancellationToken.None);
Assert.That(outcome.NodeSetText, Is.Null);
Assert.That(outcome.Diagnostics.Any(d => d.Id == "MODELGEN030"), Is.True);
}
[Test]
public void ConvertReportsConversionErrorForJsonThatIsNotAWotDocument()
{
// Well-formed JSON that carries none of the required WoT members.
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
EmbeddedText.Create("NotWot.tm.json", "{ \"unrelated\": 1 }"),
new NodesetFileOptions(),
CancellationToken.None);
Assert.That(outcome.NodeSetText, Is.Null);
Assert.That(
outcome.Diagnostics.Any(d => d.Id is "MODELGEN030" or "MODELGEN031"),
Is.True,
"a document that is not a Thing Model or Thing Description must be rejected");
}
[Test]
public void ConvertPreservesTheSuppliedNodesetFileOptionsOnFailure()
{
var options = new NodesetFileOptions();
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
EmbeddedText.Create("Malformed.tm.json", "{"),
options,
CancellationToken.None);
Assert.That(outcome.Options, Is.SameAs(options));
Assert.That(outcome.NodeSetText, Is.Null);
}
[Test]
public void ConvertProjectsConverterWarningsAsWarningDiagnostics()
{
// Source generation performs no I/O, so a tm:extends link can never
// be resolved and the converter reports an unresolved-reference
// warning that the wrapper must surface as MODELGEN032.
const string json = """
{
"@context": "https://www.w3.org/2022/wot/td/v1.1",
"@type": "tm:ThingModel",
"title": "Derived",
"links": [
{
"rel": "tm:extends",
"href": "https://example.test/unresolvable.tm.json",
"type": "application/tm+json"
}
],
"properties": {
"value": { "type": "number" }
}
}
""";
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
EmbeddedText.Create("Derived.tm.json", json),
new NodesetFileOptions(),
CancellationToken.None);
Assert.That(
outcome.Diagnostics.Any(d => d.Id is "MODELGEN031" or "MODELGEN032" or "MODELGEN033"),
Is.True,
"converter diagnostics must be projected onto the MODELGEN03x descriptors");
foreach (Diagnostic diagnostic in outcome.Diagnostics)
{
Assert.That(
diagnostic.Location.GetLineSpan().Path,
Is.EqualTo("Derived.tm.json"),
"every projected diagnostic must be anchored at the WoT source file");
}
}
[Test]
public void ConvertAnchorsUnreadableSourceDiagnosticAtTheFileWithoutAPosition()
{
var source = new ThrowingAdditionalText(
"NoPosition.tm.json",
new InvalidOperationException("unreadable"));
WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert(
source,
new NodesetFileOptions(),
CancellationToken.None);
Diagnostic diagnostic = SingleDiagnostic(outcome, "MODELGEN030");
FileLinePositionSpan span = diagnostic.Location.GetLineSpan();
Assert.That(span.Path, Is.EqualTo("NoPosition.tm.json"));
Assert.That(span.StartLinePosition.Line, Is.Zero);
Assert.That(span.StartLinePosition.Character, Is.Zero);
}
private static Diagnostic SingleDiagnostic(WotConversionOutcome outcome, string id)
{
Diagnostic[] matches = outcome.Diagnostics.Where(d => d.Id == id).ToArray();
Assert.That(
matches,
Has.Length.EqualTo(1),
$"expected exactly one {id} diagnostic but found " +
string.Join(", ", outcome.Diagnostics.Select(d => d.Id)));
return matches[0];
}
private static string WriteNodeSet(UANodeSet nodeSet)
{
using var stream = new MemoryStream();
nodeSet.Write(stream);
string xml = Encoding.UTF8.GetString(stream.ToArray());
if (xml.Length > 0 && xml[0] == '\uFEFF')
{
xml = xml[1..];
}
return xml;
}
/// <summary>
/// An AdditionalText whose content cannot be read.
/// </summary>
private sealed class ThrowingAdditionalText : AdditionalText
{
public ThrowingAdditionalText(string path, Exception exception)
{
Path = path;
m_exception = exception;
}
public override string Path { get; }
public override SourceText GetText(CancellationToken cancellationToken = default)
{
throw m_exception;
}
private readonly Exception m_exception;
}
/// <summary>
/// An AdditionalText that reports no content at all, which the Roslyn
/// contract permits when a file disappears between discovery and read.
/// </summary>
private sealed class NullTextAdditionalText : AdditionalText
{
public NullTextAdditionalText(string path)
{
Path = path;
}
public override string Path { get; }
public override SourceText GetText(CancellationToken cancellationToken = default)
{
return null!;
}
}
}
}