mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user