mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 06:31:35 +08:00
Let servers start in globalization-invariant mode
A server did not start in globalization-invariant mode, which Native AOT
and container images often enable with InvariantGlobalization. When the
server loaded its default status texts, ResourceManager created
CultureInfo("en-US"), and the runtime throws CultureNotFoundException for
every culture but the invariant one in this mode.
ResourceManager now keeps the locale id of each translation table as a
string, and falls back to its language subtag to find another region of
the same language. When the runtime cannot create the culture of a
well-formed locale id, the id is parsed into the casing of its culture
instead, and still rejected if it names a language without a region. A
malformed locale id is rejected as before. The resolved locale ids are
cached, so loading the default texts creates each culture once.
The tests reproduce both invariant-mode behaviors of the runtime: no
culture at all, and cultures that carry invariant data. Verified by
starting MinimalBoilerServer with DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
and connecting MinimalClient (browse, read, subscribe).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+5
-5
@@ -99,11 +99,11 @@ publish reports for your code. The following features are AOT-compatible:
|
||||
In your own code, avoid unbounded reflection and runtime code generation; see
|
||||
[Keep code AOT-compatible](#3-keep-code-aot-compatible).
|
||||
|
||||
Do not enable globalization-invariant mode for a server, a common size
|
||||
optimization for native executables. With the `InvariantGlobalization` property
|
||||
or the `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT` environment variable set, the
|
||||
server does not start: it reports a `CultureNotFoundException` for the `en-US`
|
||||
culture. Clients run in this mode.
|
||||
Clients and servers run in globalization-invariant mode, which the
|
||||
`InvariantGlobalization` property enables to make native executables smaller.
|
||||
In this mode a server still selects translations by locale id and falls back to
|
||||
another region of the same language. Arguments in translated texts, such as
|
||||
numbers and dates, use the invariant format for every locale.
|
||||
|
||||
## Test harness overview
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Xml;
|
||||
#if !NET6_0_OR_GREATER
|
||||
@@ -47,12 +48,27 @@ namespace Opc.Ua.Server
|
||||
/// Initializes the resource manager with the server instance that owns it.
|
||||
/// </summary>
|
||||
public ResourceManager(ApplicationConfiguration configuration)
|
||||
: this(configuration, static locale => new CultureInfo(locale))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the resource manager with the factory that creates the
|
||||
/// culture of a locale id.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The configuration of the server.</param>
|
||||
/// <param name="createCulture">Creates the culture of a locale id, or throws
|
||||
/// <see cref="CultureNotFoundException"/> when the runtime cannot create it.</param>
|
||||
internal ResourceManager(
|
||||
ApplicationConfiguration configuration,
|
||||
Func<string, CultureInfo> createCulture)
|
||||
{
|
||||
if (configuration == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
m_createCulture = createCulture ?? throw new ArgumentNullException(nameof(createCulture));
|
||||
m_translationTables = [];
|
||||
}
|
||||
|
||||
@@ -205,7 +221,7 @@ namespace Opc.Ua.Server
|
||||
|
||||
for (int ii = 0; ii < m_translationTables.Count; ii++)
|
||||
{
|
||||
availableLocales[ii] = m_translationTables![ii].Locale!.Name;
|
||||
availableLocales[ii] = m_translationTables[ii].Locale;
|
||||
}
|
||||
|
||||
return availableLocales;
|
||||
@@ -234,9 +250,9 @@ namespace Opc.Ua.Server
|
||||
throw new ArgumentNullException(nameof(text));
|
||||
}
|
||||
|
||||
var culture = new CultureInfo(locale);
|
||||
string localeId = GetLocaleId(locale, out bool isNeutral);
|
||||
|
||||
if (culture.IsNeutralCulture)
|
||||
if (isNeutral)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Cannot specify neutral locales for translation tables.",
|
||||
@@ -245,8 +261,8 @@ namespace Opc.Ua.Server
|
||||
|
||||
lock (m_lock)
|
||||
{
|
||||
TranslationTable? table = GetTable(culture.Name);
|
||||
table!.Translations[key] = text;
|
||||
TranslationTable table = GetTable(localeId);
|
||||
table.Translations[key] = text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,9 +283,9 @@ namespace Opc.Ua.Server
|
||||
throw new ArgumentNullException(nameof(translations));
|
||||
}
|
||||
|
||||
var culture = new CultureInfo(locale);
|
||||
string localeId = GetLocaleId(locale, out bool isNeutral);
|
||||
|
||||
if (culture.IsNeutralCulture)
|
||||
if (isNeutral)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Cannot specify neutral locales for translation tables.",
|
||||
@@ -278,11 +294,11 @@ namespace Opc.Ua.Server
|
||||
|
||||
lock (m_lock)
|
||||
{
|
||||
TranslationTable? table = GetTable(culture.Name);
|
||||
TranslationTable table = GetTable(localeId);
|
||||
|
||||
foreach (KeyValuePair<string, string> translation in translations)
|
||||
{
|
||||
table!.Translations[translation.Key] = translation.Value;
|
||||
table.Translations[translation.Key] = translation.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,7 +431,7 @@ namespace Opc.Ua.Server
|
||||
if (table.Translations
|
||||
.TryGetValue((info.Key ?? info.Text)!, out string? translation))
|
||||
{
|
||||
translations[table!.Locale!.Name] = translation!;
|
||||
translations[table.Locale] = translation!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,13 +446,13 @@ namespace Opc.Ua.Server
|
||||
string? translation = FindBestTranslation(
|
||||
preferredLocales.Slice(i, 1),
|
||||
(info.Key ?? info.Text)!,
|
||||
out CultureInfo culture);
|
||||
out string locale);
|
||||
if (translation != null)
|
||||
{
|
||||
// label the text with the locale of the table it came
|
||||
// from, which may be another region of the requested
|
||||
// language.
|
||||
translations[culture.Name] = translation;
|
||||
translations[locale] = translation;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,15 +466,15 @@ namespace Opc.Ua.Server
|
||||
else
|
||||
{
|
||||
// find the best translation.
|
||||
string? translatedText = info.Text;
|
||||
CultureInfo culture = CultureInfo.InvariantCulture;
|
||||
string? translatedText;
|
||||
string locale;
|
||||
|
||||
lock (m_lock)
|
||||
{
|
||||
translatedText = FindBestTranslation(
|
||||
preferredLocales,
|
||||
(info.Key ?? info.Text)!,
|
||||
out culture);
|
||||
out locale);
|
||||
|
||||
// use the default if no translation available.
|
||||
if (translatedText == null)
|
||||
@@ -470,23 +486,44 @@ namespace Opc.Ua.Server
|
||||
}
|
||||
|
||||
// construct translated localized text.
|
||||
return new LocalizedText(culture.Name, translatedText, info);
|
||||
return new LocalizedText(locale, translatedText, info);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the translations for a locale.
|
||||
/// </summary>
|
||||
private class TranslationTable
|
||||
private sealed class TranslationTable
|
||||
{
|
||||
public CultureInfo? Locale;
|
||||
public SortedDictionary<string, string> Translations = [];
|
||||
/// <summary>
|
||||
/// Creates an empty table for a locale id.
|
||||
/// </summary>
|
||||
public TranslationTable(string locale)
|
||||
{
|
||||
Locale = locale;
|
||||
Language = GetLanguage(locale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The locale id in the casing of its culture.
|
||||
/// </summary>
|
||||
public string Locale { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The language of the locale id, used to fall back to another region.
|
||||
/// </summary>
|
||||
public string Language { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The translations by key.
|
||||
/// </summary>
|
||||
public SortedDictionary<string, string> Translations { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the translation table for the locale. Creates a new table if it does not exist.
|
||||
/// </summary>
|
||||
private TranslationTable? GetTable(string locale)
|
||||
private TranslationTable GetTable(string locale)
|
||||
{
|
||||
lock (m_lock)
|
||||
{
|
||||
@@ -495,20 +532,172 @@ namespace Opc.Ua.Server
|
||||
{
|
||||
TranslationTable translationTable = m_translationTables[ii];
|
||||
|
||||
if (translationTable!.Locale!.Name == locale)
|
||||
if (translationTable.Locale == locale)
|
||||
{
|
||||
return translationTable;
|
||||
}
|
||||
}
|
||||
|
||||
// add table.
|
||||
var table = new TranslationTable { Locale = new CultureInfo(locale) };
|
||||
var table = new TranslationTable(locale);
|
||||
m_translationTables.Add(table);
|
||||
|
||||
return table;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the locale id of a translation table in the casing of its culture,
|
||||
/// and whether the locale id names a language without a region.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the runtime cannot create the culture, as in globalization-invariant
|
||||
/// mode, where it creates no culture other than the invariant culture, a
|
||||
/// well-formed locale id is parsed instead.
|
||||
/// </remarks>
|
||||
/// <exception cref="CultureNotFoundException">The locale id is malformed.</exception>
|
||||
private string GetLocaleId(string locale, out bool isNeutral)
|
||||
{
|
||||
lock (m_lock)
|
||||
{
|
||||
if (m_localeIds.TryGetValue(locale, out (string LocaleId, bool IsNeutral) cached))
|
||||
{
|
||||
isNeutral = cached.IsNeutral;
|
||||
return cached.LocaleId;
|
||||
}
|
||||
|
||||
string localeId;
|
||||
try
|
||||
{
|
||||
CultureInfo culture = m_createCulture(locale);
|
||||
localeId = culture.Name;
|
||||
isNeutral = culture.IsNeutralCulture;
|
||||
}
|
||||
catch (CultureNotFoundException) when (TryParseLocaleId(locale, out localeId, out isNeutral))
|
||||
{
|
||||
// the runtime cannot create the culture of a well-formed locale id.
|
||||
}
|
||||
|
||||
m_localeIds[locale] = (localeId, isNeutral);
|
||||
return localeId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a well-formed locale id (an RFC 5646 language tag) into the casing
|
||||
/// of its culture, and returns whether it names a language without a region.
|
||||
/// </summary>
|
||||
private static bool TryParseLocaleId(string locale, out string localeId, out bool isNeutral)
|
||||
{
|
||||
localeId = string.Empty;
|
||||
isNeutral = false;
|
||||
|
||||
string[] subtags = locale.Split('-');
|
||||
string language = subtags[0];
|
||||
if (language.Length is < 2 or > 8 || !IsAsciiLetters(language))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(locale.Length);
|
||||
builder.Append(language.ToLowerInvariant());
|
||||
|
||||
// the script and the region can only follow the language, in this order.
|
||||
int regionIndex = 1;
|
||||
bool hasRegion = false;
|
||||
bool isExtension = false;
|
||||
for (int ii = 1; ii < subtags.Length; ii++)
|
||||
{
|
||||
string subtag = subtags[ii];
|
||||
if (subtag.Length is < 1 or > 8 || !IsAsciiLettersOrDigits(subtag))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
builder.Append('-');
|
||||
|
||||
// a singleton starts an extension or a private use sequence.
|
||||
isExtension |= subtag.Length == 1;
|
||||
|
||||
if (!isExtension && ii == 1 && subtag.Length == 4 && IsAsciiLetters(subtag))
|
||||
{
|
||||
builder.Append(char.ToUpperInvariant(subtag[0]))
|
||||
.Append(subtag[1..].ToLowerInvariant());
|
||||
regionIndex = 2;
|
||||
}
|
||||
else if (!isExtension &&
|
||||
ii == regionIndex &&
|
||||
((subtag.Length == 2 && IsAsciiLetters(subtag)) ||
|
||||
(subtag.Length == 3 && IsAsciiDigits(subtag))))
|
||||
{
|
||||
builder.Append(subtag.ToUpperInvariant());
|
||||
hasRegion = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(subtag.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
localeId = builder.ToString();
|
||||
isNeutral = !hasRegion;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the language subtag of a locale id.
|
||||
/// </summary>
|
||||
private static string GetLanguage(string locale)
|
||||
{
|
||||
int index = locale.IndexOf('-', StringComparison.Ordinal);
|
||||
return index == -1 ? locale : locale[..index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the value contains only ASCII letters.
|
||||
/// </summary>
|
||||
private static bool IsAsciiLetters(string value)
|
||||
{
|
||||
foreach (char c in value)
|
||||
{
|
||||
if (c is not ((>= 'a' and <= 'z') or (>= 'A' and <= 'Z')))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the value contains only ASCII digits.
|
||||
/// </summary>
|
||||
private static bool IsAsciiDigits(string value)
|
||||
{
|
||||
foreach (char c in value)
|
||||
{
|
||||
if (c is not (>= '0' and <= '9'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the value contains only ASCII letters and digits.
|
||||
/// </summary>
|
||||
private static bool IsAsciiLettersOrDigits(string value)
|
||||
{
|
||||
foreach (char c in value)
|
||||
{
|
||||
if (c is not ((>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9')))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the null (unknown) entries from the requested locales.
|
||||
/// </summary>
|
||||
@@ -572,9 +761,9 @@ namespace Opc.Ua.Server
|
||||
private string? FindBestTranslation(
|
||||
ArrayOf<string> preferredLocales,
|
||||
string key,
|
||||
out CultureInfo culture)
|
||||
out string locale)
|
||||
{
|
||||
culture = null!;
|
||||
locale = string.Empty;
|
||||
TranslationTable? match = null;
|
||||
|
||||
if (preferredLocales.Count == 0)
|
||||
@@ -608,24 +797,24 @@ namespace Opc.Ua.Server
|
||||
|
||||
// all done if exact match found (locale ids are case-insensitive, RFC 5646).
|
||||
if (string.Equals(
|
||||
translationTable!.Locale!.Name,
|
||||
translationTable.Locale,
|
||||
preferredLocales[jj],
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
translationTable.Translations.TryGetValue(key, out string? exactMatch))
|
||||
{
|
||||
culture = translationTable.Locale;
|
||||
locale = translationTable.Locale;
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
// check for matching language but different region.
|
||||
if (match == null &&
|
||||
string.Equals(
|
||||
translationTable.Locale.TwoLetterISOLanguageName,
|
||||
translationTable.Language,
|
||||
language,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
translationTable.Translations.TryGetValue(key, out translatedText))
|
||||
{
|
||||
culture = translationTable.Locale;
|
||||
locale = translationTable.Locale;
|
||||
match = translationTable;
|
||||
}
|
||||
}
|
||||
@@ -705,6 +894,9 @@ namespace Opc.Ua.Server
|
||||
}
|
||||
|
||||
private readonly Lock m_lock = new();
|
||||
private readonly Func<string, CultureInfo> m_createCulture;
|
||||
private readonly Dictionary<string, (string LocaleId, bool IsNeutral)> m_localeIds =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly List<TranslationTable> m_translationTables;
|
||||
private Dictionary<StatusCode, TranslationInfo>? m_statusCodeMapping;
|
||||
private Dictionary<XmlQualifiedName, TranslationInfo>? m_symbolicIdMapping;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/* ========================================================================
|
||||
* 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.Xml;
|
||||
using NUnit.Framework;
|
||||
using Opc.Ua.Tests;
|
||||
|
||||
namespace Opc.Ua.Server.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests that the resource manager behaves the same when the runtime runs in
|
||||
/// globalization-invariant mode, which is common for Native AOT and container
|
||||
/// deployments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The globalization mode of a process is fixed when it starts, so the tests
|
||||
/// reproduce each mode with the factory that creates the culture of a locale id.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[Category("ResourceManager")]
|
||||
[Parallelizable]
|
||||
[SetCulture("en-us")]
|
||||
[SetUICulture("en-us")]
|
||||
public class ResourceManagerInvariantGlobalizationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The ways the runtime can create the culture of a locale id.
|
||||
/// </summary>
|
||||
public enum CultureMode
|
||||
{
|
||||
/// <summary>
|
||||
/// The runtime creates every culture with its data.
|
||||
/// </summary>
|
||||
Normal,
|
||||
|
||||
/// <summary>
|
||||
/// Globalization-invariant mode: the runtime creates only the invariant
|
||||
/// culture and throws <see cref="CultureNotFoundException"/> for any
|
||||
/// other locale id.
|
||||
/// </summary>
|
||||
Invariant,
|
||||
|
||||
/// <summary>
|
||||
/// Globalization-invariant mode with predefined cultures turned off: the
|
||||
/// runtime creates every culture, but with the data of the invariant
|
||||
/// culture, for example the language "iv".
|
||||
/// </summary>
|
||||
InvariantData
|
||||
}
|
||||
|
||||
[TestCase(CultureMode.Normal)]
|
||||
[TestCase(CultureMode.Invariant)]
|
||||
[TestCase(CultureMode.InvariantData)]
|
||||
public void LoadDefaultTextRegistersEnglishStatusTexts(CultureMode mode)
|
||||
{
|
||||
using ResourceManager resourceManager = CreateResourceManager(mode);
|
||||
|
||||
resourceManager.LoadDefaultText();
|
||||
|
||||
var input = new ServiceResult(
|
||||
StatusCodes.BadTimeout,
|
||||
new XmlQualifiedName("BadTimeout"),
|
||||
LocalizedText.Null);
|
||||
LocalizedText translated = resourceManager.Translate(["en-US"], input).LocalizedText;
|
||||
string[] locales = resourceManager.GetAvailableLocales();
|
||||
Assert.That(locales, Has.Length.EqualTo(1));
|
||||
Assert.That(locales[0], Is.EqualTo("en-US"));
|
||||
Assert.That(translated.Locale, Is.EqualTo("en-US"));
|
||||
Assert.That(translated.Text, Is.EqualTo("BadTimeout"));
|
||||
}
|
||||
|
||||
[TestCase(CultureMode.Normal, "de-de", "de-DE")]
|
||||
[TestCase(CultureMode.Invariant, "de-de", "de-DE")]
|
||||
[TestCase(CultureMode.Normal, "sr-latn-rs", "sr-Latn-RS")]
|
||||
[TestCase(CultureMode.Invariant, "sr-latn-rs", "sr-Latn-RS")]
|
||||
[TestCase(CultureMode.Normal, "es-419", "es-419")]
|
||||
[TestCase(CultureMode.Invariant, "es-419", "es-419")]
|
||||
public void AddUsesTheCanonicalCasingOfTheLocaleId(
|
||||
CultureMode mode,
|
||||
string locale,
|
||||
string expected)
|
||||
{
|
||||
using ResourceManager resourceManager = CreateResourceManager(mode);
|
||||
|
||||
resourceManager.Add("greeting", locale, "Hello");
|
||||
|
||||
LocalizedText translated = resourceManager.Translate([expected], "greeting", "Hi");
|
||||
string[] locales = resourceManager.GetAvailableLocales();
|
||||
Assert.That(locales, Has.Length.EqualTo(1));
|
||||
Assert.That(locales[0], Is.EqualTo(expected));
|
||||
Assert.That(translated.Locale, Is.EqualTo(expected));
|
||||
Assert.That(translated.Text, Is.EqualTo("Hello"));
|
||||
}
|
||||
|
||||
[TestCase(CultureMode.Normal)]
|
||||
[TestCase(CultureMode.Invariant)]
|
||||
[TestCase(CultureMode.InvariantData)]
|
||||
public void TranslateFallsBackToAnotherRegionOfTheLanguage(CultureMode mode)
|
||||
{
|
||||
using ResourceManager resourceManager = CreateResourceManager(mode);
|
||||
resourceManager.Add("greeting", "de-DE", "Hallo");
|
||||
|
||||
LocalizedText translated = resourceManager.Translate(["de-AT"], "greeting", "Hello");
|
||||
|
||||
Assert.That(translated.Locale, Is.EqualTo("de-DE"));
|
||||
Assert.That(translated.Text, Is.EqualTo("Hallo"));
|
||||
}
|
||||
|
||||
[TestCase(CultureMode.Normal)]
|
||||
[TestCase(CultureMode.Invariant)]
|
||||
[TestCase(CultureMode.InvariantData)]
|
||||
public void TranslateMulLabelsRegionFallbackWithItsOwnLocale(CultureMode mode)
|
||||
{
|
||||
using ResourceManager resourceManager = CreateResourceManager(mode);
|
||||
resourceManager.Add("greeting", "de-DE", "Hallo");
|
||||
|
||||
LocalizedText translated = resourceManager.Translate(
|
||||
["mul", "de-AT"],
|
||||
new LocalizedText("greeting", "en-US", "Hello"));
|
||||
|
||||
Assert.That(translated.Locale, Is.EqualTo("de-DE"));
|
||||
Assert.That(translated.Text, Is.EqualTo("Hallo"));
|
||||
}
|
||||
|
||||
[TestCase(CultureMode.Normal, "de")]
|
||||
[TestCase(CultureMode.Invariant, "de")]
|
||||
[TestCase(CultureMode.Normal, "zh-Hans")]
|
||||
[TestCase(CultureMode.Invariant, "zh-Hans")]
|
||||
public void AddRejectsNeutralLocale(CultureMode mode, string locale)
|
||||
{
|
||||
using ResourceManager resourceManager = CreateResourceManager(mode);
|
||||
|
||||
Assert.That(
|
||||
() => resourceManager.Add("greeting", locale, "Hallo"),
|
||||
Throws.ArgumentException);
|
||||
Assert.That(resourceManager.GetAvailableLocales(), Is.Empty);
|
||||
}
|
||||
|
||||
[TestCase(CultureMode.Normal)]
|
||||
[TestCase(CultureMode.Invariant)]
|
||||
[TestCase(CultureMode.InvariantData)]
|
||||
public void AddRejectsMalformedLocale(CultureMode mode)
|
||||
{
|
||||
using ResourceManager resourceManager = CreateResourceManager(mode);
|
||||
|
||||
Assert.That(
|
||||
() => resourceManager.Add("greeting", "!!", "Hallo"),
|
||||
Throws.TypeOf<CultureNotFoundException>());
|
||||
Assert.That(resourceManager.GetAvailableLocales(), Is.Empty);
|
||||
}
|
||||
|
||||
private static ResourceManager CreateResourceManager(CultureMode mode)
|
||||
{
|
||||
var configuration = new ApplicationConfiguration(NUnitTelemetryContext.Create());
|
||||
return mode switch
|
||||
{
|
||||
CultureMode.Normal => new ResourceManager(configuration),
|
||||
CultureMode.Invariant => new ResourceManager(configuration, CreateInvariantModeCulture),
|
||||
CultureMode.InvariantData => new ResourceManager(configuration, CreateInvariantDataCulture),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode))
|
||||
};
|
||||
}
|
||||
|
||||
private static CultureInfo CreateInvariantModeCulture(string name)
|
||||
{
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return CultureInfo.InvariantCulture;
|
||||
}
|
||||
throw new CultureNotFoundException(
|
||||
nameof(name),
|
||||
name,
|
||||
"Only the invariant culture is supported in globalization-invariant mode.");
|
||||
}
|
||||
|
||||
private static CultureInfo CreateInvariantDataCulture(string name)
|
||||
{
|
||||
return name.Length == 0 ? CultureInfo.InvariantCulture : new InvariantDataCulture(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A culture that carries the data of the invariant culture, as the runtime
|
||||
/// creates it in globalization-invariant mode when predefined cultures are
|
||||
/// turned off.
|
||||
/// </summary>
|
||||
private sealed class InvariantDataCulture : CultureInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the culture of a locale id.
|
||||
/// </summary>
|
||||
public InvariantDataCulture(string name)
|
||||
: base(name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string TwoLetterISOLanguageName => "iv";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user