Fix empty LocalizedText binary field encoding (#4709)

# Description

Omit empty `Locale` and `Text` fields from the OPC UA Binary
DataEncoding for `LocalizedText`, as required by OPC 10000-6, Section
5.2.2.14, Table 24. Add wire-format tests for empty and non-empty
fields, including translation-backed localized text.

Validation:

- `git diff --check` passed before commit.
- `dotnet build tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj
--framework net10.0 --no-restore --verbosity minimal
-p:NuGetAudit=false` could not complete because access to
`https://api.nuget.org/v3/index.json` failed with `NU1301` and a TLS
handshake error.

## Related Issues

No related issue.

## Checklist

- [x] 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.
- [x] 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.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Cristi Pogacean
2026-10-10 08:05:11 +02:00
committed by GitHub
parent 9099aee13e
commit 9344354aaa
9 changed files with 223 additions and 20 deletions
+17
View File
@@ -8,6 +8,7 @@ covers cross-cutting changes.
## Contents
- [General principles](#general-principles)
- [Empty LocalizedText fields in 2.0](#empty-localizedtext-fields-in-20)
- [Per-version migration index](#per-version-migration-index)
- [Migrating code that used the exposed diagnostics locks](#migrating-code-that-used-the-exposed-diagnostics-locks)
- [Why there is no `[Obsolete]` shim](#why-there-is-no-obsolete-shim)
@@ -42,6 +43,22 @@ covers cross-cutting changes.
- [Migrating from 1.04 to 1.05](#migrating-from-104-to-105)
- [Support](#support)
## Empty LocalizedText fields in 2.0
`LocalizedText.Locale` and `LocalizedText.Text` now expose empty strings as
`null`, matching the absent-field representation in OPC 10000-6, Table 24.
Plain values constructed with both fields null or empty are the null value,
including `new LocalizedText(string.Empty)`. Whitespace is not normalized.
Use null-aware checks rather than relying on `Text == string.Empty`.
Equality and hashing use these canonical fields, so empty inputs compare and
hash like null inputs before and after encoding. Translation keys, templates,
arguments, dictionaries and fallback metadata are retained. An explicitly
empty selected translation remains absent text rather than selecting fallback
text; its `Text` property returns `null`. Translation-backed values retain
their metadata and are not necessarily `IsNull`, even when both exposed fields
are null.
## General principles
1. All API that is replaced with newer API is marked `[Obsolete]` and
+21 -12
View File
@@ -123,7 +123,7 @@ namespace Opc.Ua
{
m_translation = null;
m_locale = null;
m_text = text;
m_text = Normalize(text);
}
/// <summary>
@@ -134,8 +134,8 @@ namespace Opc.Ua
[JsonConstructor]
public LocalizedText(string? locale, string? text)
{
m_text = text;
m_locale = locale;
m_text = Normalize(text);
m_locale = Normalize(locale);
m_translation = LocalizedTextFormatAndTranslation.Create(locale, text);
}
@@ -148,7 +148,7 @@ namespace Opc.Ua
public LocalizedText(string key, string locale, string text)
{
m_text = text;
m_locale = locale;
m_locale = Normalize(locale);
m_translation = LocalizedTextFormatAndTranslation.Create(key, locale, text);
}
@@ -213,7 +213,7 @@ namespace Opc.Ua
internal LocalizedText(LocalizedTextFormatAndTranslation? translation)
{
m_translation = translation;
m_locale = translation?.GetLocale();
m_locale = Normalize(translation?.GetLocale());
m_text = translation?.GetText(m_locale);
}
@@ -225,22 +225,23 @@ namespace Opc.Ua
/// <param name="translation">The translation information</param>
internal LocalizedText(string? locale, string? text, LocalizedTextFormatAndTranslation? translation)
{
m_text = text;
m_locale = locale;
// Retain an explicitly empty selected template so formatting does not substitute fallback text.
m_text = translation == null ? Normalize(text) : text;
m_locale = Normalize(locale);
m_translation = translation;
}
/// <summary>
/// The locale used to create the text.
/// The locale used to create the text. Empty locale identifiers are returned as null.
/// </summary>
public string? Locale
=> m_locale ?? m_translation?.GetLocale();
=> Normalize(m_locale ?? m_translation?.GetLocale());
/// <summary>
/// The localized text.
/// The localized text. Empty text is returned as null.
/// </summary>
public string? Text
=> IsMultiLanguage ? m_text : m_translation?.FormatText(Locale, m_text) ?? m_text;
=> Normalize(IsMultiLanguage ? m_text : m_translation?.FormatText(Locale, m_text) ?? m_text);
/// <summary>
/// Translations
@@ -456,10 +457,18 @@ namespace Opc.Ua
/// </summary>
internal bool TryGetTextOnly(out string? text)
{
text = m_text;
text = Normalize(m_text);
return m_locale is null && m_translation is null;
}
/// <summary>
/// Canonicalizes empty fields to the absent representation used by OPC UA encodings.
/// </summary>
private static string? Normalize(string? value)
{
return string.IsNullOrEmpty(value) ? null : value;
}
/// <summary>
/// The translation state, with a deferred "mul" JSON text decoded on
/// first use.
@@ -1048,7 +1048,7 @@ namespace Opc.Ua.Features.Tests
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(alarm.Comment!.Value.Locale, Is.EqualTo("en"));
Assert.That(alarm.Comment.Value.Text, Is.EqualTo(string.Empty));
Assert.That(alarm.Comment.Value.Text, Is.Null);
}
/// <summary>
@@ -1015,7 +1015,7 @@ namespace Opc.Ua.Scales.Tests
ScaleSystemHandle minimal = await m_fixture.Manager.CreateScaleSystemAsync(
m_fixture.Name("Minimal"),
s => s.WithIdentification(ScalesNodeManagerTests.Identity("MIN")));
Assert.That(minimal.System.ProcessStateMessage!.Value.Text, Is.Empty);
Assert.That(minimal.System.ProcessStateMessage!.Value.Text, Is.Null);
Assert.That(minimal.PackML, Is.Null);
}
}
@@ -1,5 +1,35 @@
/* ========================================================================
* 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.Collections.Generic;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Types.Tests.BuiltIn
{
@@ -249,5 +279,149 @@ namespace Opc.Ua.Types.Tests.BuiltIn
Is.EqualTo(localizedText.Translations["fr-FR"]),
"French translation should be the same");
}
[TestCase(null, null)]
[TestCase("", null)]
[TestCase(null, "")]
[TestCase("", "")]
public void EmptyFieldsConstructTheNullValue(string? locale, string? text)
{
var value = new LocalizedText(locale, text);
Assert.That(value.Locale, Is.Null);
Assert.That(value.Text, Is.Null);
Assert.That(value.IsNull, Is.True);
Assert.That(value, Is.EqualTo(LocalizedText.Null));
Assert.That(value.GetHashCode(), Is.EqualTo(LocalizedText.Null.GetHashCode()));
Assert.That(Variant.From(value), Is.EqualTo(Variant.From(LocalizedText.Null)));
}
[Test]
public void TextOnlyEmptyInputConstructsTheNullValue()
{
var value = new LocalizedText(string.Empty);
Assert.That(value.Text, Is.Null);
Assert.That(value.IsNull, Is.True);
Assert.That(value, Is.EqualTo(LocalizedText.Null));
}
[Test]
public void WhitespaceFieldsArePreserved()
{
var value = new LocalizedText(" ", " ");
Assert.That(value.Locale, Is.EqualTo(" "));
Assert.That(value.Text, Is.EqualTo(" "));
Assert.That(value.IsNull, Is.False);
}
[Test]
public void TranslationConstructionNormalizesFieldsWithoutDiscardingMetadata()
{
var info = new TranslationInfo("Key", string.Empty, string.Empty);
LocalizedText[] values =
[
new LocalizedText("Key", string.Empty, string.Empty),
new LocalizedText("Key", string.Empty, string.Empty, 7),
new LocalizedText(info),
new LocalizedText(string.Empty, string.Empty, info),
new LocalizedText(new Dictionary<string, string> { [string.Empty] = string.Empty }),
new LocalizedText("Key", new Dictionary<string, string> { [string.Empty] = string.Empty })
];
foreach (LocalizedText value in values)
{
Assert.That(value.Locale, Is.Null);
Assert.That(value.Text, Is.Null);
Assert.That(value.IsNull, Is.False);
Assert.That(value, Is.EqualTo(LocalizedText.Null));
Assert.That(value.GetHashCode(), Is.EqualTo(LocalizedText.Null.GetHashCode()));
}
Assert.That(values[0].TranslationInfo.Key, Is.EqualTo("Key"));
Assert.That(values[1].TranslationInfo.Args, Is.EqualTo(new object[] { 7 }));
Assert.That(values[2].TranslationInfo, Is.EqualTo(info));
Assert.That(values[4].Translations, Has.Count.EqualTo(1));
}
[Test]
public void FormattedEmptyTextIsNormalizedWithoutDiscardingTemplate()
{
var value = new LocalizedText("Key", "en-US", "{0}", string.Empty);
Assert.That(value.Text, Is.Null);
Assert.That(value.TranslationInfo.Text, Is.EqualTo("{0}"));
Assert.That(value.TranslationInfo.Args, Is.EqualTo(new object[] { string.Empty }));
}
/// <summary>
/// Binary encoding preserves the canonical field values and ordinary value equality.
/// </summary>
[TestCase(null, null, 0x00)]
[TestCase("", "", 0x00)]
[TestCase("", null, 0x00)]
[TestCase(null, "", 0x00)]
[TestCase("en-US", null, 0x01)]
[TestCase("en-US", "", 0x01)]
[TestCase(null, "Text", 0x02)]
[TestCase("", "Text", 0x02)]
[TestCase("en-US", "Text", 0x03)]
[TestCase(" ", " ", 0x03)]
public void BinaryEncodingOmitsNullOrEmptyFields(
string? locale,
string? text,
byte expectedEncodingMask)
{
ITelemetryContext telemetryContext = NUnitTelemetryContext.Create();
var messageContext = ServiceMessageContext.CreateEmpty(telemetryContext);
byte[] buffer;
var value = new LocalizedText(locale, text);
using (var encoder = new BinaryEncoder(messageContext))
{
encoder.WriteLocalizedText(null, value);
buffer = encoder.CloseAndReturnBuffer() ??
throw new AssertionException("The encoder returned no buffer.");
}
Assert.That(buffer[0], Is.EqualTo(expectedEncodingMask));
using var decoder = new BinaryDecoder(buffer, messageContext);
LocalizedText decoded = decoder.ReadLocalizedText(null);
Assert.That(decoded.Locale, Is.EqualTo(value.Locale));
Assert.That(decoded.Text, Is.EqualTo(value.Text));
Assert.That(decoded, Is.EqualTo(value));
}
/// <summary>
/// Translation-backed and formatted values expose canonical fields before binary encoding.
/// </summary>
[TestCase("", "Text", false, 0x02)]
[TestCase("en-US", "", false, 0x01)]
[TestCase("", "", false, 0x00)]
[TestCase("en-US", "", true, 0x01)]
[TestCase("", "", true, 0x00)]
[TestCase("en-US", "Text", true, 0x03)]
[TestCase(" ", " ", false, 0x03)]
public void TranslationBackedFieldsUseCanonicalBinaryEncoding(
string locale,
string text,
bool formatText,
byte expectedEncodingMask)
{
ITelemetryContext telemetryContext = NUnitTelemetryContext.Create();
var messageContext = ServiceMessageContext.CreateEmpty(telemetryContext);
LocalizedText value = formatText
? new LocalizedText("Key", locale, "{0}", text)
: new LocalizedText("Key", locale, text);
byte[] buffer;
using (var encoder = new BinaryEncoder(messageContext))
{
encoder.WriteLocalizedText(null, value);
buffer = encoder.CloseAndReturnBuffer() ??
throw new AssertionException("The encoder returned no buffer.");
}
Assert.That(value.TranslationInfo.Key, Is.EqualTo("Key"));
Assert.That(buffer[0], Is.EqualTo(expectedEncodingMask));
using var decoder = new BinaryDecoder(buffer, messageContext);
LocalizedText decoded = decoder.ReadLocalizedText(null);
Assert.That(decoded.Locale, Is.EqualTo(value.Locale));
Assert.That(decoded.Text, Is.EqualTo(value.Text));
Assert.That(decoded, Is.EqualTo(value));
}
}
}
@@ -272,7 +272,7 @@ namespace Opc.Ua.Types.Tests
var selected = new LocalizedText("de-DE", string.Empty, fallback);
Assert.That(selected.Locale, Is.EqualTo("de-DE"));
Assert.That(selected.Text, Is.Empty);
Assert.That(selected.Text, Is.Null);
Assert.That(selected.TranslationInfo, Is.EqualTo(fallback));
}
@@ -323,10 +323,10 @@ namespace Opc.Ua.Types.Tests.BuiltIn
{
var plain = new LocalizedText(text);
Assert.That(plain.TryGetTextOnly(out string? raw), Is.True);
Assert.That(raw, Is.SameAs(text));
Assert.That(raw, Is.SameAs(string.IsNullOrEmpty(text) ? null : text));
var emptyLocale = new LocalizedText(string.Empty, text);
Assert.That(emptyLocale.TryGetTextOnly(out _), Is.False);
Assert.That(new Variant(emptyLocale).GetLocalizedText().Locale, Is.EqualTo(string.Empty));
Assert.That(emptyLocale.TryGetTextOnly(out _), Is.True);
Assert.That(new Variant(emptyLocale).GetLocalizedText().Locale, Is.Null);
}
var formatted = new LocalizedText("key", null!, "Value {0}", 7);
Assert.That(formatted.TryGetTextOnly(out _), Is.False);
@@ -10087,7 +10087,10 @@ namespace Opc.Ua.Types.Tests.Encoders
var value = new LocalizedText("en", string.Empty);
Variant decoded = RoundTripVariantValue(Variant.From(value), raw);
Assert.That(decoded.GetLocalizedText(), Is.EqualTo(value));
LocalizedText localizedText = decoded.GetLocalizedText();
Assert.That(localizedText.Locale, Is.EqualTo("en"));
Assert.That(localizedText.Text, Is.Null);
Assert.That(localizedText, Is.EqualTo(value));
}
[Theory]
@@ -430,7 +430,7 @@ namespace Opc.Ua.Types.Tests.Encoders
{
decoder.PushNamespace(kNs);
LocalizedText text = decoder.ReadLocalizedText("Text");
Assert.That(text.Locale, Is.Empty);
Assert.That(text.Locale, Is.Null);
Assert.That(text.Text, Is.EqualTo("Site"));
}
}