Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/MigrationGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
33 changes: 21 additions & 12 deletions src/Opc.Ua.Types/BuiltIn/LocalizedText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public LocalizedText(string? text)
{
m_translation = null;
m_locale = null;
m_text = text;
m_text = Normalize(text);
}

/// <summary>
Expand All @@ -134,8 +134,8 @@ public LocalizedText(string? text)
[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);
}

Expand All @@ -148,7 +148,7 @@ public LocalizedText(string? locale, string? text)
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);
}

Expand Down Expand Up @@ -213,7 +213,7 @@ public LocalizedText(string key, IReadOnlyDictionary<string, string> translation
internal LocalizedText(LocalizedTextFormatAndTranslation? translation)
{
m_translation = translation;
m_locale = translation?.GetLocale();
m_locale = Normalize(translation?.GetLocale());
m_text = translation?.GetText(m_locale);
}

Expand All @@ -225,22 +225,23 @@ internal LocalizedText(LocalizedTextFormatAndTranslation? translation)
/// <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
Expand Down Expand Up @@ -456,10 +457,18 @@ public LocalizedText WithTranslationInfo(TranslationInfo info)
/// </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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1048,7 +1048,7 @@ public void OnAcknowledgeWithLocaleOnlyCommentUpdatesComment()

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>
Expand Down
2 changes: 1 addition & 1 deletion tests/Opc.Ua.Scales.Tests/ScaleTypeControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1015,7 +1015,7 @@ public async Task ScaleSystemWithoutIdentificationIsRejectedAsync()
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);
}
}
Expand Down
174 changes: 174 additions & 0 deletions tests/Opc.Ua.Types.Tests/BuiltIn/LocalizedTextTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down Expand Up @@ -249,5 +279,149 @@ public void LocalizedText_MulLocale_DeepCopy()
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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ public void EmptySelectedTextDoesNotBecomeFallbackText()
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));
}

Expand Down
6 changes: 3 additions & 3 deletions tests/Opc.Ua.Types.Tests/BuiltIn/VariantStorageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,10 @@ public void TextOnlyEligibilityUsesRawLocaleAndTranslationState()
{
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);
Expand Down
5 changes: 4 additions & 1 deletion tests/Opc.Ua.Types.Tests/Encoders/BinaryEncoderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10087,7 +10087,10 @@ public void WriteVariantValueWithLocaleOnlyLocalizedTextRoundTripsCorrectly(bool
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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ public void XmlWhitespaceOnlyStringIsEmptyWhenOptedIn()
{
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"));
}
}
Expand Down
Loading