/* ========================================================================
* 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
{
///
/// 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 carrying a diagnostic
/// and no NodeSet.
///
[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 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());
}
[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;
}
///
/// An AdditionalText whose content cannot be read.
///
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;
}
///
/// An AdditionalText that reports no content at all, which the Roslyn
/// contract permits when a file disappears between discovery and read.
///
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!;
}
}
}
}